固化七月迭代审计治理进展以隔离线上热修

Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
This commit is contained in:
2026-08-03 09:47:22 +08:00
parent cf2ff0ac1c
commit b3499adfca
114 changed files with 16961 additions and 2782 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,25 @@ package auth
import (
"context"
"sort"
"strconv"
accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
securityAudit accountauditapp.Writer
accountStore *postgres.AccountStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
@@ -25,6 +31,12 @@ type Service struct {
logger *zap.Logger
}
// SetSecurityAudit 注入后台认证安全状态的统一审计接缝。
func (s *Service) SetSecurityAudit(db *gorm.DB, writer accountauditapp.Writer) {
s.db = db
s.securityAudit = writer
}
func New(
accountStore *postgres.AccountStore,
accountRoleStore *postgres.AccountRoleStore,
@@ -54,15 +66,23 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询账号失败")
}
device := req.Device
if device == "" {
device = "web"
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.Password)); err != nil {
s.logger.Warn("登录失败:密码错误", zap.String("username", req.Username), zap.String("ip", clientIP))
return nil, errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
appErr := errors.New(errors.CodeInvalidCredentials, "用户名或密码错误")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
if account.Status != 1 {
s.logger.Warn("登录失败:账号已禁用", zap.String("username", req.Username), zap.Uint("user_id", account.ID))
return nil, errors.New(errors.CodeAccountDisabled, "账号已禁用")
appErr := errors.New(errors.CodeAccountDisabled, "账号已禁用")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
// 检查店铺状态(代理账号必须关联店铺且店铺必须启用)
@@ -71,21 +91,21 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
if err != nil {
if err == gorm.ErrRecordNotFound {
s.logger.Warn("登录失败:关联店铺不存在", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
return nil, errors.New(errors.CodeShopNotFound, "关联店铺不存在")
appErr := errors.New(errors.CodeShopNotFound, "关联店铺不存在")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺失败")
}
if shop.Status != constants.StatusEnabled {
s.logger.Warn("登录失败:关联店铺已禁用", zap.String("username", req.Username), zap.Uint("shop_id", *account.ShopID))
return nil, errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
appErr := errors.New(errors.CodeShopDisabled, "店铺已禁用,无法登录")
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "拒绝后台账号登录", constants.AuditResultDenied, device, appErr)
return nil, appErr
}
}
device := req.Device
if device == "" {
device = "web"
}
var shopID, enterpriseID uint
if account.ShopID != nil {
shopID = *account.ShopID
@@ -106,8 +126,16 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
accessToken, refreshToken, err := s.tokenManager.GenerateTokenPair(ctx, tokenInfo)
if err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogin, "后台账号登录失败", constants.AuditResultFailed, device, err)
return nil, err
}
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAuthLogin, Summary: "后台账号登录", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":" + device,
Authentication: authenticationData(account.ID, device, "password", "authenticated"),
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogin, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
}
permissions, menus, buttons, err := s.getUserPermissionsAndMenus(ctx, account.ID, account.UserType, device)
if err != nil {
@@ -139,6 +167,9 @@ func (s *Service) Login(ctx context.Context, req *dto.LoginRequest, clientIP str
func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string) error {
if err := s.tokenManager.RevokeToken(ctx, accessToken); err != nil {
if account := s.loadAuditAccount(ctx); account != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAuthLogout, "后台账号退出登录失败", constants.AuditResultFailed, "", err)
}
return err
}
@@ -147,6 +178,15 @@ func (s *Service) Logout(ctx context.Context, accessToken, refreshToken string)
s.logger.Warn("撤销 refresh token 失败", zap.Error(err))
}
}
if account := s.loadAuditAccount(ctx); account != nil {
if err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAuthLogout, Summary: "后台账号退出登录", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":session",
Authentication: authenticationData(account.ID, "", "token", "revoked"),
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAuthLogout, account.Username, contextRequestID(ctx), contextRequestID(ctx), strconv.Itoa(errors.CodeInternalError), err)
}
}
return nil
}
@@ -185,15 +225,34 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword,
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
return errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
appErr := errors.New(errors.CodeInvalidOldPassword, "旧密码错误")
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "拒绝修改账号密码", constants.AuditResultDenied, "", appErr)
return appErr
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
}
if err := s.accountStore.UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
if s.db == nil || s.securityAudit == nil {
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewAccountStore(tx, nil).UpdatePassword(ctx, userID, string(hashedPassword), userID); err != nil {
return err
}
return s.securityAudit.WriteAccountSecurity(ctx, tx, accountauditapp.SecurityAudit{
ActionCode: constants.AuditActionAccountPasswordChanged, Summary: "修改账号密码", Result: constants.AuditResultSuccess,
ActorID: account.ID, ActorName: account.Username, Account: account,
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":password",
Authentication: authenticationData(account.ID, "", "password", "changed"),
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
AfterData: map[string]any{"credentials_configured": true},
})
}); err != nil {
s.recordSecurityFailure(ctx, account, constants.AuditActionAccountPasswordChanged, "修改账号密码失败", constants.AuditResultFailed, "", err)
return errors.Wrap(errors.CodeInternalError, err, "更新密码失败")
}
@@ -206,6 +265,63 @@ func (s *Service) ChangePassword(ctx context.Context, userID uint, oldPassword,
return nil
}
func (s *Service) writeSecurityAudit(ctx context.Context, account *model.Account, audit accountauditapp.SecurityAudit) error {
if s.db == nil || s.securityAudit == nil || account == nil || account.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "后台认证审计接缝未配置")
}
audit.Account = account
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.securityAudit.WriteAccountSecurity(ctx, tx, audit)
})
}
func (s *Service) recordSecurityFailure(ctx context.Context, account *model.Account, actionCode, summary, result, device string, originalErr error) {
if account == nil || account.ID == 0 {
return
}
errorCode := strconv.Itoa(errors.CodeInternalError)
if appErr, ok := originalErr.(*errors.AppError); ok {
errorCode = strconv.Itoa(appErr.Code)
}
err := s.writeSecurityAudit(ctx, account, accountauditapp.SecurityAudit{
ActionCode: actionCode, Summary: summary, Result: result, ErrorCode: errorCode, ErrorSummary: summary,
ActorID: account.ID, ActorName: account.Username,
AuthenticationKey: "account:" + strconv.FormatUint(uint64(account.ID), 10) + ":security",
Authentication: authenticationData(account.ID, device, "password", result),
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(actionCode, account.Username, contextRequestID(ctx), contextRequestID(ctx), errorCode, err)
}
}
func (s *Service) loadAuditAccount(ctx context.Context) *model.Account {
userID := middleware.GetUserIDFromContext(ctx)
return s.loadAuditAccountByID(ctx, userID)
}
func (s *Service) loadAuditAccountByID(ctx context.Context, userID uint) *model.Account {
if userID == 0 || s.db == nil {
return nil
}
var account model.Account
if err := s.db.WithContext(ctx).Unscoped().First(&account, userID).Error; err != nil {
return nil
}
return &account
}
func authenticationData(accountID uint, device, method, state string) map[string]any {
return map[string]any{"account_id": accountID, "device": device, "auth_method": method, "state": state}
}
func contextRequestID(ctx context.Context) string {
value := middleware.GetRequestIDFromContext(ctx)
if value == nil {
return ""
}
return *value
}
func (s *Service) getUserPermissions(ctx context.Context, userID uint) ([]string, error) {
accountRoles, err := s.accountRoleStore.GetByAccountID(ctx, userID)
if err != nil {

View File

@@ -4,10 +4,12 @@ package client_auth
import (
"context"
stderrors "errors"
"regexp"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
@@ -23,6 +25,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -52,6 +55,7 @@ type Service struct {
logger *zap.Logger
wechatCache kernel.CacheInterface
customerBinding *customerBinding.Service
accessAudit accessauditapp.Writer
}
// New 创建 C 端认证服务实例
@@ -68,6 +72,7 @@ func New(
redisClient *redis.Client,
logger *zap.Logger,
binding *customerBinding.Service,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
@@ -83,6 +88,7 @@ func New(
logger: logger,
wechatCache: wechat.NewRedisCache(redisClient),
customerBinding: binding,
accessAudit: accessAudit,
}
}
@@ -293,24 +299,38 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
return nil, errors.New(errors.CodeAlreadyBoundPhone)
appErr := errors.New(errors.CodeAlreadyBoundPhone)
if customer, loadErr := s.customerStore.GetByID(ctx, customerID); loadErr == nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
}
return nil, appErr
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
appErr := errors.New(errors.CodeAlreadyBoundPhone)
if existed.CustomerID != customerID {
return nil, errors.New(errors.CodePhoneAlreadyBound)
appErr = errors.New(errors.CodePhoneAlreadyBound)
}
return nil, errors.New(errors.CodeAlreadyBoundPhone)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, appErr)
return nil, appErr
}
now := time.Now()
@@ -321,8 +341,38 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP
VerifiedAt: &now,
Status: 1,
}
if err := s.phoneStore.Create(ctx, record); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
var count int64
if err := tx.Model(&model.PersonalCustomerPhone{}).
Where("customer_id = ? AND is_primary = ? AND status = ?", customerID, true, 1).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if count > 0 {
return errors.New(errors.CodeAlreadyBoundPhone)
}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.Phone, 1).First(&existed).Error; err == nil {
if existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
}
return errors.New(errors.CodeAlreadyBoundPhone)
} else if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
}
if err := tx.Create(record).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
}
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil,
map[string]any{"phone": record.Phone}, "手机号已绑定", constants.AuditResultSuccess,
))
})
if err != nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err)
return nil, err
}
return &dto.BindPhoneResponse{
@@ -336,41 +386,86 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeOldPhoneMismatch)
appErr := errors.New(errors.CodeOldPhoneMismatch)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
}
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, nil, appErr)
return nil, appErr
}
if primary.Phone != req.OldPhone {
return nil, errors.New(errors.CodeOldPhoneMismatch)
appErr := errors.New(errors.CodeOldPhoneMismatch)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
return nil, errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
Where("id = ? AND customer_id = ?", primary.ID, customerID).
Updates(map[string]any{
var beforeData map[string]any
var failurePhone *model.PersonalCustomerPhone
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1).
First(primary).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeOldPhoneMismatch)
}
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if primary.Phone != req.OldPhone {
return errors.New(errors.CodeOldPhoneMismatch)
}
current := *primary
failurePhone = &current
beforeData = map[string]any{"phone": primary.Phone}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
}
if err := tx.Model(primary).Updates(map[string]any{
"phone": req.NewPhone,
"verified_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
return errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
}
primary.Phone = req.NewPhone
primary.VerifiedAt = &now
return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData,
map[string]any{"phone": primary.Phone}, "手机号已更换", constants.AuditResultSuccess,
))
})
if err != nil {
if failurePhone == nil {
failurePhone = primary
}
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, failurePhone, err)
return nil, err
}
return &dto.ChangePhoneResponse{
@@ -379,6 +474,56 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
}, nil
}
func personalPhoneAudit(
actionCode, summary string,
customer *model.PersonalCustomer,
phone *model.PersonalCustomerPhone,
beforeData, afterData map[string]any,
subjectSummary, result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, SubjectVisibility: constants.AuditSubjectDetail,
SubjectSummary: subjectSummary, SubjectData: afterData,
}
if phone != nil && phone.ID != 0 {
change.PersonalPhones = []accessauditapp.PersonalCustomerPhoneChange{{
Phone: phone, BeforeData: beforeData, AfterData: afterData,
}}
}
return change
}
func (s *Service) recordPersonalFailure(
ctx context.Context,
actionCode, summary string,
customer *model.PersonalCustomer,
phone *model.PersonalCustomerPhone,
originalErr error,
) {
if customer == nil || customer.ID == 0 {
return
}
subjectSummary := "个人身份资料操作失败"
change := personalPhoneAudit(actionCode, summary, customer, phone, nil, nil, subjectSummary, personalAuditFailureResult(originalErr))
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, change, originalErr)
}
func personalAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound,
errors.CodeAlreadyBoundPhone, errors.CodePhoneAlreadyBound, errors.CodeOldPhoneMismatch,
errors.CodeVerificationCodeInvalid:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// Logout A7 退出登录
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
@@ -509,13 +654,20 @@ func (s *Service) loginByOpenID(
avatar string,
appType string,
) (uint, bool, error) {
if s.db == nil || s.accessAudit == nil {
return 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
var (
customerID uint
isNewUser bool
customerID uint
isNewUser bool
identityAudit *accessauditapp.ChangeAudit
)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
cid, created, change, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
customerID = cid
identityAudit = change
isNewUser = created
if findErr != nil {
return findErr
}
@@ -523,11 +675,26 @@ func (s *Service) loginByOpenID(
return bindErr
}
customerID = cid
isNewUser = created
if identityAudit != nil {
return s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit)
}
return nil
})
if err != nil {
if identityAudit != nil && customerID != 0 && !isNewUser {
if identityAudit.ActionCode == constants.AuditActionPersonalCustomerProfileUpdated {
identityAudit.Summary = "同步个人资料失败"
identityAudit.SubjectSummary = "个人资料同步失败"
} else {
identityAudit.Summary = "同步个人微信主体失败"
identityAudit.SubjectSummary = "微信登录身份同步失败"
}
identityAudit.Result = personalAuditFailureResult(err)
identityAudit.SubjectData = nil
identityAudit.PersonalOpenIDs = nil
restorePersonalCustomerSnapshot(identityAudit)
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, *identityAudit, err)
}
return 0, false, err
}
@@ -544,7 +711,7 @@ func (s *Service) findOrCreateCustomer(
nickname string,
avatar string,
appType string,
) (uint, bool, error) {
) (uint, bool, *accessauditapp.ChangeAudit, error) {
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
@@ -552,26 +719,36 @@ func (s *Service) findOrCreateCustomer(
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, errors.New(errors.CodeCustomerNotFound)
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
}
beforeData := personalCustomerProfileData(customer)
changed := false
if nickname != "" && customer.Nickname != nickname {
customer.Nickname = nickname
changed = true
}
if avatar != "" && customer.AvatarURL != avatar {
customer.AvatarURL = avatar
changed = true
}
var change *accessauditapp.ChangeAudit
if changed {
pending := personalProfileSyncAudit(customer, beforeData)
change = &pending
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
return customer.ID, false, change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, nil
return customer.ID, false, change, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
}
if unionID != "" {
@@ -579,14 +756,16 @@ func (s *Service) findOrCreateCustomer(
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, errors.New(errors.CodeCustomerNotFound)
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
}
beforeData := personalCustomerProfileData(customer)
record := &model.PersonalCustomerOpenID{
CustomerID: customer.ID,
AppID: appID,
@@ -594,8 +773,9 @@ func (s *Service) findOrCreateCustomer(
UnionID: unionID,
AppType: appType,
}
change := personalWechatAudit(customer, record, beforeData, nil, appID, appType, constants.AuditResultSuccess)
if createErr := openidStore.Create(ctx, record); createErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
}
if nickname != "" && customer.Nickname != nickname {
@@ -605,12 +785,14 @@ func (s *Service) findOrCreateCustomer(
customer.AvatarURL = avatar
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, nil
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
return customer.ID, false, &change, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
}
}
@@ -622,7 +804,7 @@ func (s *Service) findOrCreateCustomer(
Status: 1,
}
if err := customerStore.Create(ctx, newCustomer); err != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
}
record := &model.PersonalCustomerOpenID{
@@ -632,11 +814,64 @@ func (s *Service) findOrCreateCustomer(
UnionID: unionID,
AppType: appType,
}
change := personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
if err := openidStore.Create(ctx, record); err != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
return newCustomer.ID, true, &change, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
}
return newCustomer.ID, true, nil
change = personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
return newCustomer.ID, true, &change, nil
}
func personalProfileSyncAudit(customer *model.PersonalCustomer, beforeData map[string]any) accessauditapp.ChangeAudit {
return accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "同步个人资料", Result: constants.AuditResultSuccess,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalCustomerProfileData(customer),
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已同步",
SubjectData: personalCustomerProfileData(customer),
}
}
func personalWechatAudit(
customer *model.PersonalCustomer,
openID *model.PersonalCustomerOpenID,
beforeData, afterData map[string]any,
appID, appType, result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerWechatIdentityUpdated, Summary: "同步个人微信主体", Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "微信登录身份已同步",
SubjectData: map[string]any{"app_id": appID, "app_type": appType},
}
if openID != nil && openID.ID != 0 {
change.PersonalOpenIDs = []accessauditapp.PersonalCustomerOpenIDChange{{
OpenID: openID, AfterData: map[string]any{"app_id": openID.AppID, "app_type": openID.AppType},
}}
}
return change
}
func personalCustomerProfileData(customer *model.PersonalCustomer) map[string]any {
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
}
func restorePersonalCustomerSnapshot(change *accessauditapp.ChangeAudit) {
if change.PersonalCustomer == nil || change.BeforeData == nil {
return
}
customer := *change.PersonalCustomer
if nickname, ok := change.BeforeData["nickname"].(string); ok {
customer.Nickname = nickname
}
if avatarURL, ok := change.BeforeData["avatar_url"].(string); ok {
customer.AvatarURL = avatarURL
}
change.PersonalCustomer = &customer
}
// checkCardBoundToDevice 检查卡是否绑定了设备
@@ -703,6 +938,9 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp
// 根据资产标识符查找或创建测试客户并直接签发 JWT无需微信 OAuth
// ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用
func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) {
if s.db == nil || s.accessAudit == nil {
return "", 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
assetType, assetID, _, err := s.resolveAsset(ctx, identifier)
if err != nil {
return "", 0, false, err
@@ -719,13 +957,18 @@ func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint
devOpenID := "dev_test_" + identifier
devAppID := "dev_test_app"
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
cid, created, identityAudit, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
if findErr != nil {
return findErr
}
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
return bindErr
}
if identityAudit != nil {
if auditErr := s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit); auditErr != nil {
return auditErr
}
}
customerID = cid
isNewUser = created
return nil

View File

@@ -0,0 +1,117 @@
package device
import (
"context"
"strconv"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func (s *Service) appendCSVBatchAllocationAudit(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
succeededIDs []uint,
failedItems []dto.AllocationDeviceFailedItem,
targetShopID uint,
) error {
linkage := auditcontext.From(ctx)
if s.auditWriter == nil || linkage.ActorKind != constants.AuditActorSystemTask ||
linkage.ActorID != constants.TaskTypeDeviceImport || linkage.Source != constants.AuditSourceWorker ||
linkage.CorrelationID == "" {
return nil
}
devicesByID := make(map[uint]*model.Device, len(devices))
for _, device := range devices {
if device != nil {
devicesByID[device.ID] = device
}
}
rootEventID := stableBatchEventID("root", linkage.CorrelationID)
children := make([]audit.AppendInput, 0, len(succeededIDs)+len(failedItems))
for _, deviceID := range succeededIDs {
if device := devicesByID[deviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, true, ""))
}
}
for _, item := range failedItems {
if device := devicesByID[item.DeviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, false, item.Reason))
}
}
result := constants.AuditResultSuccess
if len(succeededIDs) > 0 && len(failedItems) > 0 {
result = constants.AuditResultPartial
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootEventID, ActionCode: constants.AuditActionDeviceBatchAllocationCompleted,
Summary: "设备CSV批量分配完成", Result: result,
CorrelationID: linkage.CorrelationID,
BatchTotal: len(succeededIDs) + len(failedItems), SuccessCount: len(succeededIDs), FailCount: len(failedItems),
Metadata: map[string]any{"operation_type": constants.DeviceImportOperationAssignShop, "target_shop_id": targetShopID},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDeviceBatchTask, Key: linkage.CorrelationID, DisplayName: linkage.CorrelationID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{"task_no": linkage.CorrelationID, "operation_type": constants.DeviceImportOperationAssignShop},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func deviceBatchChild(
device *model.Device,
parentEventID string,
correlationID string,
targetShopID uint,
succeeded bool,
reason string,
) audit.AppendInput {
resourceID := strconv.FormatUint(uint64(device.ID), 10)
before := map[string]any{"shop_id": device.ShopID, "status": device.Status}
after := before
result := constants.AuditResultFailed
summary := "设备批量分配失败"
if succeeded {
after = map[string]any{"shop_id": targetShopID, "status": constants.DeviceStatusDistributed}
result = constants.AuditResultSuccess
summary = "设备批量分配成功"
}
return audit.AppendInput{
EventID: stableBatchEventID("device", correlationID+":"+resourceID),
ActionCode: constants.AuditActionDeviceBatchAllocationItem, Summary: summary,
Result: result, ErrorSummary: reason, CorrelationID: correlationID, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &resourceID, Key: deviceAuditKey(device), DisplayName: deviceAuditKey(device),
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchItem,
IdentitySnapshot: map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
},
BeforeData: before, AfterData: after,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}},
}
}
func stableBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device-batch:"+kind+":"+key)).String()
}
func deviceAuditKey(device *model.Device) string {
for _, value := range []string{device.VirtualNo, device.IMEI, device.SN} {
if value != "" {
return value
}
}
return strconv.FormatUint(uint64(device.ID), 10)
}

View File

@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -42,6 +43,7 @@ type Service struct {
packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
auditWriter *auditinfra.Writer
}
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
@@ -168,6 +170,7 @@ func New(
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
packageExpiryQuery: packageexpiry.NewQuery(db),
auditWriter: auditinfra.NewWriter(nil, nil),
}
}
@@ -678,7 +681,10 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
return err
}
return s.appendCSVBatchAllocationAudit(ctx, tx, devices, deviceIDs, failedItems, targetShopID)
})
if err != nil {

View File

@@ -2,7 +2,9 @@ package enterprise
import (
"context"
stderrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -12,6 +14,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
@@ -19,14 +22,23 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
accessAudit accessauditapp.Writer
}
func New(db *gorm.DB, enterpriseStore *postgres.EnterpriseStore, shopStore *postgres.ShopStore, accountStore *postgres.AccountStore) *Service {
// New 创建企业生命周期服务。
func New(
db *gorm.DB,
enterpriseStore *postgres.EnterpriseStore,
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
enterpriseStore: enterpriseStore,
shopStore: shopStore,
accountStore: accountStore,
accessAudit: accessAudit,
}
}
@@ -35,52 +47,71 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
enterprise := &model.Enterprise{
EnterpriseName: req.EnterpriseName, EnterpriseCode: req.EnterpriseCode, OwnerShopID: req.OwnerShopID,
LegalPerson: req.LegalPerson, ContactName: req.ContactName, ContactPhone: req.ContactPhone,
BusinessLicense: req.BusinessLicense, Province: req.Province, City: req.City,
District: req.District, Address: req.Address, Status: constants.StatusEnabled,
}
enterprise.Creator = currentUserID
enterprise.Updater = currentUserID
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
err := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeAgent && req.OwnerShopID == nil {
err := errors.New(errors.CodeForbidden, "代理账号不能创建平台主管企业")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
if req.EnterpriseCode != "" {
existing, _ := s.enterpriseStore.GetByCode(ctx, req.EnterpriseCode)
if existing != nil {
return nil, errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
err := errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
}
existingAccount, _ := s.accountStore.GetByPhone(ctx, req.LoginPhone)
if existingAccount != nil {
return nil, errors.New(errors.CodePhoneExists, "手机号已被使用")
err := errors.New(errors.CodePhoneExists, "手机号已被使用")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
var ownerShop *model.Shop
if req.OwnerShopID != nil {
_, err := s.shopStore.GetByID(ctx, *req.OwnerShopID)
if err := middleware.CanManageShop(ctx, *req.OwnerShopID); err != nil {
err = errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
var err error
ownerShop, err = s.shopStore.GetByID(ctx, *req.OwnerShopID)
if err != nil {
return nil, errors.New(errors.CodeShopNotFound, "归属店铺不存在或无效")
err = errors.New(errors.CodeShopNotFound, "归属店铺不存在或无效")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, nil, nil, err)
return nil, err
}
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", enterprise, ownerShop, nil, appErr)
return nil, appErr
}
var enterprise *model.Enterprise
var account *model.Account
err = s.db.Transaction(func(tx *gorm.DB) error {
enterprise = &model.Enterprise{
EnterpriseName: req.EnterpriseName,
EnterpriseCode: req.EnterpriseCode,
OwnerShopID: req.OwnerShopID,
LegalPerson: req.LegalPerson,
ContactName: req.ContactName,
ContactPhone: req.ContactPhone,
BusinessLicense: req.BusinessLicense,
Province: req.Province,
City: req.City,
District: req.District,
Address: req.Address,
Status: constants.StatusEnabled,
}
enterprise.Creator = currentUserID
enterprise.Updater = currentUserID
if err := tx.WithContext(ctx).Create(enterprise).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建企业失败")
}
@@ -100,18 +131,27 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
return errors.Wrap(errors.CodeInternalError, err, "创建企业账号失败")
}
return nil
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCreated, Summary: "创建企业", OperatorID: currentUserID,
Enterprise: enterprise, Shop: ownerShop,
Accounts: []accessauditapp.AccountChange{{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
AfterData: map[string]any{"status": account.Status, "credentials_configured": true},
}},
AfterData: enterpriseProfileData(enterprise),
})
})
if err != nil {
failureEnterprise := *enterprise
failureEnterprise.ID = 0
s.recordFailure(ctx, constants.AuditActionEnterpriseCreated, "创建企业失败", &failureEnterprise, ownerShop, nil, err)
return nil, err
}
ownerShopName := ""
if enterprise.OwnerShopID != nil {
if shop, err := s.shopStore.GetByID(ctx, *enterprise.OwnerShopID); err == nil {
ownerShopName = shop.ShopName
}
if ownerShop != nil {
ownerShopName = ownerShop.ShopName
}
return &dto.CreateEnterpriseResp{
@@ -140,28 +180,205 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateEnterpriseReq) (*dt
// Update 更新企业信息
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateEnterpriseRequest) (*model.Enterprise, error) {
// 获取当前用户 ID
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 查询企业
var enterprise *model.Enterprise
var before *model.Enterprise
var ownerShop *model.Shop
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
locked, err := lockEnterprise(ctx, tx, id)
if err != nil {
return err
}
beforeValue := *locked
before = &beforeValue
enterprise = locked
ownerShop = loadEnterpriseOwnerShop(tx, locked.OwnerShopID)
if req.EnterpriseCode != nil && *req.EnterpriseCode != locked.EnterpriseCode {
var count int64
if err := tx.Model(&model.Enterprise{}).Where("enterprise_code = ? AND id <> ?", *req.EnterpriseCode, id).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "检查企业编号失败")
}
if count > 0 {
return errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
}
locked.EnterpriseCode = *req.EnterpriseCode
}
applyEnterpriseUpdate(locked, req)
locked.Updater = currentUserID
if err := tx.Save(locked).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业失败")
}
if !enterpriseProfileChanged(before, locked) {
return nil
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseUpdated, Summary: "更新企业基础资料", OperatorID: currentUserID,
Enterprise: locked, Shop: ownerShop,
BeforeData: enterpriseProfileData(before), AfterData: enterpriseProfileData(locked),
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业更新审计失败")
}
return nil
})
if err != nil {
if before != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseUpdated, "更新企业基础资料失败", before, ownerShop, nil, err)
}
return nil, err
}
return enterprise, nil
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
var before *model.Enterprise
var ownerShop *model.Shop
var accounts []*model.Account
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
enterprise, err := lockEnterprise(ctx, tx, id)
if err != nil {
return err
}
beforeValue := *enterprise
before = &beforeValue
ownerShop = loadEnterpriseOwnerShop(tx, enterprise.OwnerShopID)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("enterprise_id = ?", id).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询企业账号失败")
}
enterprise.Status = status
enterprise.Updater = currentUserID
if err := tx.Save(enterprise).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新企业状态失败")
}
if err := tx.Model(&model.Account{}).
Where("enterprise_id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updater": currentUserID,
}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "同步更新企业账号状态失败")
}
if before.Status == status {
return nil
}
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
for _, account := range accounts {
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": status},
})
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseStatusUpdated, Summary: "更新企业状态", OperatorID: currentUserID,
Enterprise: enterprise, Shop: ownerShop, Accounts: accountChanges,
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": status},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业状态审计失败")
}
return nil
})
if err != nil {
if before != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseStatusUpdated, "更新企业状态失败", before, ownerShop, accounts, err)
}
return err
}
return nil
}
func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "企业审计接缝未配置")
}
if err := middleware.CanManageEnterprise(ctx, id, s.enterpriseStore); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
var ownerShop *model.Shop
if enterprise.OwnerShopID != nil {
ownerShop, _ = s.shopStore.GetByID(ctx, *enterprise.OwnerShopID)
}
// 检查企业编号唯一性(如果修改了编号)
if req.EnterpriseCode != nil && *req.EnterpriseCode != enterprise.EnterpriseCode {
existing, err := s.enterpriseStore.GetByCode(ctx, *req.EnterpriseCode)
if err == nil && existing != nil && existing.ID != id {
return nil, errors.New(errors.CodeEnterpriseCodeExists, "企业编号已存在")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
s.recordFailure(ctx, constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码失败", enterprise, ownerShop, nil, appErr)
return appErr
}
var accounts []*model.Account
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
enterprise, err = lockEnterprise(ctx, tx, id)
if err != nil {
return err
}
enterprise.EnterpriseCode = *req.EnterpriseCode
ownerShop = loadEnterpriseOwnerShop(tx, enterprise.OwnerShopID)
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("enterprise_id = ?", id).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询企业账号失败")
}
if err := tx.Model(&model.Account{}).Where("enterprise_id = ?", id).Updates(map[string]interface{}{
"password": string(hashedPassword), "updater": currentUserID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业账号密码失败")
}
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
for _, account := range accounts {
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
BeforeData: map[string]any{"credentials_configured": account.Password != ""},
AfterData: map[string]any{"credentials_configured": true, "state": "changed"},
})
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterprisePasswordUpdated, Summary: "更新企业账号密码", OperatorID: currentUserID,
Enterprise: enterprise, Shop: ownerShop, Accounts: accountChanges,
BeforeData: map[string]any{"credentials_configured": len(accounts) > 0},
AfterData: map[string]any{"credentials_configured": len(accounts) > 0, "state": "changed"},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业改密审计失败")
}
return nil
})
if err != nil {
if enterprise != nil {
s.recordFailure(ctx, constants.AuditActionEnterprisePasswordUpdated, "更新企业账号密码失败", enterprise, ownerShop, accounts, err)
}
return err
}
return nil
}
// 更新字段
func applyEnterpriseUpdate(enterprise *model.Enterprise, req *dto.UpdateEnterpriseRequest) {
if req.EnterpriseName != nil {
enterprise.EnterpriseName = *req.EnterpriseName
}
@@ -189,69 +406,81 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateEnterprise
if req.Address != nil {
enterprise.Address = *req.Address
}
enterprise.Updater = currentUserID
if err := s.enterpriseStore.Update(ctx, enterprise); err != nil {
return nil, err
}
return enterprise, nil
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
func enterpriseProfileData(enterprise *model.Enterprise) map[string]any {
return map[string]any{
"enterprise_name": enterprise.EnterpriseName, "enterprise_code": enterprise.EnterpriseCode,
"owner_shop_id": enterprise.OwnerShopID, "legal_person": enterprise.LegalPerson,
"contact_name": enterprise.ContactName, "contact_phone": enterprise.ContactPhone,
"business_license": enterprise.BusinessLicense, "province": enterprise.Province,
"city": enterprise.City, "district": enterprise.District, "address": enterprise.Address,
"status": enterprise.Status,
}
}
enterprise, err := s.enterpriseStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
func enterpriseProfileChanged(before, after *model.Enterprise) bool {
return before.EnterpriseName != after.EnterpriseName || before.EnterpriseCode != after.EnterpriseCode ||
before.LegalPerson != after.LegalPerson || before.ContactName != after.ContactName ||
before.ContactPhone != after.ContactPhone || before.BusinessLicense != after.BusinessLicense ||
before.Province != after.Province || before.City != after.City || before.District != after.District ||
before.Address != after.Address
}
func lockEnterprise(ctx context.Context, tx *gorm.DB, id uint) (*model.Enterprise, error) {
var enterprise model.Enterprise
query := middleware.ApplyOwnerShopFilter(ctx, tx.WithContext(ctx).Where("id = ?", id))
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).First(&enterprise).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业失败")
}
return &enterprise, nil
}
return s.db.Transaction(func(tx *gorm.DB) error {
enterprise.Status = status
enterprise.Updater = currentUserID
if err := tx.WithContext(ctx).Save(enterprise).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新企业状态失败")
}
if err := tx.WithContext(ctx).Model(&model.Account{}).
Where("enterprise_id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updater": currentUserID,
}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "同步更新企业账号状态失败")
}
func loadEnterpriseOwnerShop(tx *gorm.DB, ownerShopID *uint) *model.Shop {
if ownerShopID == nil {
return nil
})
}
var shop model.Shop
if err := tx.Unscoped().First(&shop, *ownerShopID).Error; err != nil {
return nil
}
return &shop
}
func (s *Service) UpdatePassword(ctx context.Context, id uint, password string) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary string,
enterprise *model.Enterprise,
ownerShop *model.Shop,
accounts []*model.Account,
originalErr error,
) {
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
for _, account := range accounts {
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, Role: constants.AuditResourceRoleEnterpriseAccount,
})
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: enterpriseAuditFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
Accounts: accountChanges, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
_, err := s.enterpriseStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
func enterpriseAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound,
errors.CodeEnterpriseCodeExists, errors.CodePhoneExists, errors.CodeShopNotFound:
return constants.AuditResultDenied
}
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "密码加密失败")
}
return s.db.WithContext(ctx).Model(&model.Account{}).
Where("enterprise_id = ?", id).
Updates(map[string]interface{}{
"password": string(hashedPassword),
"updater": currentUserID,
}).Error
return constants.AuditResultFailed
}
func (s *Service) GetByID(ctx context.Context, id uint) (*model.Enterprise, error) {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -12,26 +13,33 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AuthorizationService struct {
db *gorm.DB
enterpriseStore *postgres.EnterpriseStore
iotCardStore *postgres.IotCardStore
authorizationStore *postgres.EnterpriseCardAuthorizationStore
logger *zap.Logger
accessAudit accessauditapp.Writer
}
func NewAuthorizationService(
db *gorm.DB,
enterpriseStore *postgres.EnterpriseStore,
iotCardStore *postgres.IotCardStore,
authorizationStore *postgres.EnterpriseCardAuthorizationStore,
logger *zap.Logger,
accessAudit accessauditapp.Writer,
) *AuthorizationService {
return &AuthorizationService{
db: db,
enterpriseStore: enterpriseStore,
iotCardStore: iotCardStore,
authorizationStore: authorizationStore,
logger: logger,
accessAudit: accessAudit,
}
}
@@ -405,6 +413,9 @@ func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint,
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "用户信息无效")
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
}
record, err := s.authorizationStore.GetByIDWithJoin(ctx, id)
if err != nil {
@@ -420,23 +431,97 @@ func (s *AuthorizationService) UpdateRecordRemark(ctx context.Context, id uint,
case constants.UserTypeAgent:
// 代理用户: 只能修改自己创建的授权记录
if record.AuthorizedBy != userID {
return nil, errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
err := errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
s.recordRemarkFailure(ctx, record, err)
return nil, err
}
case constants.UserTypeEnterprise:
// 企业用户: 禁止修改授权记录备注
return nil, errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
err := errors.New(errors.CodeForbidden, "企业用户不允许修改授权记录备注")
s.recordRemarkFailure(ctx, record, err)
return nil, err
default:
return nil, errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
}
if err := s.authorizationStore.UpdateRemarkWithConstraint(ctx, id, remark, record.AuthorizedBy); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "授权记录不存在")
}
err := errors.New(errors.CodeForbidden, "无权限修改授权记录备注")
s.recordRemarkFailure(ctx, record, err)
return nil, err
}
return s.GetRecordDetail(ctx, id)
var enterprise model.Enterprise
var card model.IotCard
var auth model.EnterpriseCardAuthorization
var ownerShop *model.Shop
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&auth, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "授权记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败")
}
if userType == constants.UserTypeAgent && auth.AuthorizedBy != userID {
return errors.New(errors.CodeForbidden, "只能修改自己创建的授权记录备注")
}
if err := tx.First(&enterprise, auth.EnterpriseID).Error; err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err := tx.First(&card, auth.CardID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询授权卡失败")
}
if enterprise.OwnerShopID != nil {
var shop model.Shop
if err := tx.Unscoped().First(&shop, *enterprise.OwnerShopID).Error; err == nil {
ownerShop = &shop
}
}
beforeRemark := auth.Remark
if beforeRemark == remark {
return nil
}
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id = ?", auth.ID).Update("remark", remark).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新授权备注失败")
}
auth.Remark = remark
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注",
OperatorID: userID, Enterprise: &enterprise, Shop: ownerShop,
Cards: []accessauditapp.IotCardChange{{
Card: &card, Relation: constants.AuditResourceRelationReference,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{
Authorization: &auth,
BeforeData: map[string]any{"remark": beforeRemark}, AfterData: map[string]any{"remark": remark},
}},
})
})
if err != nil {
s.recordRemarkFailure(ctx, record, err)
return nil, err
}
result, err := s.GetRecordDetail(ctx, id)
if err != nil {
return nil, err
}
return result, nil
}
func (s *AuthorizationService) recordRemarkFailure(ctx context.Context, record *postgres.AuthorizationWithJoin, originalErr error) {
if record == nil {
return
}
enterprise, _ := s.enterpriseStore.GetByID(ctx, record.EnterpriseID)
card, _ := s.iotCardStore.GetByID(ctx, record.CardID)
auth := &model.EnterpriseCardAuthorization{
ID: record.ID, EnterpriseID: record.EnterpriseID, CardID: record.CardID,
AuthorizedBy: record.AuthorizedBy, AuthorizerType: record.AuthorizerType,
AuthorizedAt: record.AuthorizedAt, RevokedBy: record.RevokedBy, RevokedAt: record.RevokedAt, Remark: record.Remark,
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardRemarkUpdated, Summary: "更新企业卡授权备注失败",
Result: enterpriseCardFailureResult(originalErr), OperatorID: middleware.GetUserIDFromContext(ctx),
Enterprise: enterprise, Cards: []accessauditapp.IotCardChange{{Card: card}},
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{Authorization: auth}},
}, originalErr)
}
func parseDate(dateStr string) (time.Time, error) {

View File

@@ -2,8 +2,10 @@ package enterprise_card
import (
"context"
stderrors "errors"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -11,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
@@ -18,6 +21,7 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
iotCardStore *postgres.IotCardStore
accessAudit accessauditapp.Writer
}
func New(
@@ -25,12 +29,14 @@ func New(
enterpriseStore *postgres.EnterpriseStore,
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
iotCardStore *postgres.IotCardStore,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
enterpriseStore: enterpriseStore,
enterpriseCardAuthStore: enterpriseCardAuthStore,
iotCardStore: iotCardStore,
accessAudit: accessAudit,
}
}
@@ -204,10 +210,20 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
}
if err := validateEnterpriseCardActor(ctx); err != nil {
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := s.loadOwnerShop(ctx, enterprise.OwnerShopID)
iccids, err := s.resolveICCIDsForAllocate(ctx, req)
if err != nil {
@@ -231,6 +247,14 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
cardIDToICCID[card.IotCardID] = card.ICCID
allCandidateIDs = append(allCandidateIDs, card.IotCardID)
}
auditCardList, err := s.iotCardStore.GetByIDs(ctx, allCandidateIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡审计快照失败")
}
cardIDMap := make(map[uint]*model.IotCard, len(auditCardList))
for _, card := range auditCardList {
cardIDMap[card.ID] = card
}
// 检测已被其他企业授权的卡,阻止重复授权
conflictAuths, err := s.enterpriseCardAuthStore.GetConflictingAuthsByCardIDs(ctx, enterpriseID, allCandidateIDs)
@@ -239,7 +263,12 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
}
cardIDsToAllocate := make([]uint, 0, len(allCandidateIDs))
seenAllocate := make(map[uint]struct{}, len(allCandidateIDs))
for _, cardID := range allCandidateIDs {
if _, seen := seenAllocate[cardID]; seen {
continue
}
seenAllocate[cardID] = struct{}{}
if _, conflict := conflictAuths[cardID]; conflict {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
ICCID: cardIDToICCID[cardID],
@@ -274,12 +303,41 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
}
if len(auths) > 0 {
if err := s.enterpriseCardAuthStore.BatchCreate(ctx, auths); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建授权记录失败")
auditResult := constants.AuditResultSuccess
if resp.FailCount > 0 {
auditResult = constants.AuditResultPartial
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.CreateInBatches(auths, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建授权记录失败")
}
cards := make([]accessauditapp.IotCardChange, 0, len(auths))
authorizations := make([]accessauditapp.EnterpriseCardAuthorizationChange, 0, len(auths))
for _, auth := range auths {
card := cardIDMap[auth.CardID]
cards = append(cards, accessauditapp.IotCardChange{
Card: card, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡已授权给企业",
})
authorizations = append(authorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth, AfterData: map[string]any{"authorized": true, "remark": auth.Remark},
})
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardsAllocated, Summary: "向企业授权卡",
Result: auditResult, OperatorID: currentUserID, Enterprise: enterprise, Shop: ownerShop,
Cards: cards, CardAuthorizations: authorizations,
BeforeData: map[string]any{"authorized_card_count": 0},
AfterData: map[string]any{"authorized_card_count": len(auths)},
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseCardsAllocated, "向企业授权卡失败", enterprise, ownerShop, cardChanges(cardIDMap, allCandidateIDs), err)
return nil, err
}
}
resp.SuccessCount = len(cardIDsToAllocate)
resp.SuccessCount = len(auths)
return resp, nil
}
@@ -289,10 +347,20 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业卡授权审计接缝未配置")
}
if err := validateEnterpriseCardActor(ctx); err != nil {
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := s.loadOwnerShop(ctx, enterprise.OwnerShopID)
iccids, err := s.resolveICCIDsForRecall(ctx, enterpriseID, req)
if err != nil {
@@ -324,6 +392,7 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
}
cardIDsToRecall := make([]uint, 0)
seenRecall := make(map[uint]struct{}, len(iccids))
for _, iccid := range iccids {
card, exists := cardMap[iccid]
if !exists {
@@ -340,20 +409,130 @@ func (s *Service) RecallCards(ctx context.Context, enterpriseID uint, req *dto.R
})
continue
}
if _, seen := seenRecall[card.ID]; seen {
continue
}
seenRecall[card.ID] = struct{}{}
cardIDsToRecall = append(cardIDsToRecall, card.ID)
}
if len(cardIDsToRecall) > 0 {
if err := s.enterpriseCardAuthStore.BatchUpdateStatus(ctx, enterpriseID, cardIDsToRecall, 0); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "回收授权失败")
var recalled []*model.EnterpriseCardAuthorization
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("enterprise_id = ? AND card_id IN ? AND revoked_at IS NULL", enterpriseID, cardIDsToRecall).
Find(&recalled).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询有效卡授权失败")
}
if len(recalled) == 0 {
return nil
}
now := time.Now()
ids := make([]uint, 0, len(recalled))
cards := make([]accessauditapp.IotCardChange, 0, len(recalled))
authorizations := make([]accessauditapp.EnterpriseCardAuthorizationChange, 0, len(recalled))
for _, auth := range recalled {
ids = append(ids, auth.ID)
before := *auth
auth.RevokedBy = &currentUserID
auth.RevokedAt = &now
cards = append(cards, accessauditapp.IotCardChange{
Card: cardIDMap[auth.CardID],
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡授权已回收",
})
authorizations = append(authorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth,
BeforeData: map[string]any{"revoked_by": before.RevokedBy, "revoked_at": before.RevokedAt},
AfterData: map[string]any{"revoked_by": currentUserID, "revoked_at": now},
})
}
if err := tx.Model(&model.EnterpriseCardAuthorization{}).Where("id IN ? AND revoked_at IS NULL", ids).
Updates(map[string]any{"revoked_by": currentUserID, "revoked_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "回收卡授权失败")
}
result := constants.AuditResultSuccess
if len(resp.FailedItems) > 0 {
result = constants.AuditResultPartial
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseCardsRecalled, Summary: "回收企业卡授权",
Result: result, OperatorID: currentUserID, Enterprise: enterprise, Shop: ownerShop,
Cards: cards, CardAuthorizations: authorizations,
BeforeData: map[string]any{"authorized_card_count": len(recalled)},
AfterData: map[string]any{"authorized_card_count": 0},
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionEnterpriseCardsRecalled, "回收企业卡授权失败", enterprise, ownerShop, cardChanges(cardIDMap, cardIDsToRecall), err)
return nil, err
}
resp.SuccessCount = len(recalled)
}
resp.SuccessCount = len(cardIDsToRecall)
resp.FailCount = len(resp.FailedItems)
return resp, nil
}
func validateEnterpriseCardActor(ctx context.Context) error {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
return nil
default:
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
}
func (s *Service) loadOwnerShop(ctx context.Context, ownerShopID *uint) *model.Shop {
if ownerShopID == nil {
return nil
}
shop, err := postgres.NewShopStore(s.db, nil).GetByID(ctx, *ownerShopID)
if err != nil {
return nil
}
return shop
}
func cardChanges(cardMap map[uint]*model.IotCard, ids []uint) []accessauditapp.IotCardChange {
changes := make([]accessauditapp.IotCardChange, 0, len(ids))
for _, id := range ids {
if card := cardMap[id]; card != nil {
changes = append(changes, accessauditapp.IotCardChange{Card: card})
}
}
return changes
}
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary string,
enterprise *model.Enterprise,
ownerShop *model.Shop,
cards []accessauditapp.IotCardChange,
originalErr error,
) {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: enterpriseCardFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
Cards: cards, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
func enterpriseCardFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound,
errors.CodeIotCardNotFound, errors.CodeIotCardStatusNotAllowed, errors.CodeCannotAuthorizeToOthersEnterprise,
errors.CodeCannotAuthorizeOthersCard, errors.CodeCannotAuthorizeBoundCard, errors.CodeCardAlreadyAuthorized,
errors.CodeCardNotAuthorized, errors.CodeCannotRevokeOthersAuthorization:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
func (s *Service) ListCards(ctx context.Context, enterpriseID uint, req *dto.EnterpriseCardListReq) (*dto.EnterpriseCardPageResult, error) {
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {

View File

@@ -4,7 +4,9 @@ import (
"context"
stderrors "errors"
"time"
"unicode/utf8"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -14,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
@@ -24,6 +27,7 @@ type Service struct {
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
logger *zap.Logger
accessAudit accessauditapp.Writer
}
func New(
@@ -34,6 +38,7 @@ func New(
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore,
logger *zap.Logger,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
@@ -43,6 +48,7 @@ func New(
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseCardAuthStore: enterpriseCardAuthStore,
logger: logger,
accessAudit: accessAudit,
}
}
@@ -52,12 +58,26 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 验证企业存在
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err := validateAllocateDevicesRequest(req); err != nil {
return nil, err
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
}
if err := validateEnterpriseDeviceActor(ctx); err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err)
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr)
return nil, permissionErr
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := loadEnterpriseDeviceOwnerShop(ctx, s.db, enterprise.OwnerShopID)
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForAllocate(ctx, req)
@@ -67,7 +87,7 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
// 查询所有设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
if err := enterpriseDeviceQuery(ctx, s.db).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -93,172 +113,221 @@ func (s *Service) AllocateDevices(ctx context.Context, enterpriseID uint, req *d
AuthorizedDevices: make([]dto.AuthorizedDeviceItem, 0),
}
devicesToAllocate := make([]*model.Device, 0)
devicesToAllocate := selectDevicesForAllocate(
deviceNos, deviceMap, activeAuthEnterpriseMap, enterpriseID, userType, currentShopID, resp,
)
if len(devicesToAllocate) > 0 {
items, err := s.allocateDevices(ctx, enterprise, ownerShop, devicesToAllocate, req, currentUserID, userType, resp)
if err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备失败", enterprise, ownerShop, devicesToAllocate, err)
return nil, err
}
resp.AuthorizedDevices = append(resp.AuthorizedDevices, items...)
}
resp.SuccessCount = len(resp.AuthorizedDevices)
resp.FailCount = len(resp.FailedItems)
if resp.SuccessCount == 0 {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesAllocated, "向企业授权设备被拒绝", enterprise, ownerShop, devicePointers(deviceMap, deviceIDs), errors.New(errors.CodeInvalidStatus, "没有设备满足授权条件"))
}
return resp, nil
}
// selectDevicesForAllocate 按既有设备状态、归属和有效授权规则筛选可授权设备。
func selectDevicesForAllocate(
deviceNos []string,
deviceMap map[string]*model.Device,
activeAuthEnterpriseMap map[uint]uint,
enterpriseID uint,
userType int,
currentShopID uint,
resp *dto.AllocateDevicesResp,
) []*model.Device {
devices := make([]*model.Device, 0, len(deviceNos))
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
for _, deviceNo := range deviceNos {
if _, exists := seenDeviceNos[deviceNo]; exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "请求中设备号重复",
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "请求中设备号重复"})
continue
}
seenDeviceNos[deviceNo] = struct{}{}
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备不存在",
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
continue
}
// 验证设备状态(必须是"已分销"状态)
if device.Status != 2 {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备状态不正确,必须是已分销状态",
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "设备状态不正确,必须是已分销状态"})
continue
}
// 验证设备所有权(除非是超级管理员或平台用户)
if userType == constants.UserTypeAgent {
if device.ShopID == nil || *device.ShopID != currentShopID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "无权操作此设备",
})
continue
}
if userType == constants.UserTypeAgent && (device.ShopID == nil || *device.ShopID != currentShopID) {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
continue
}
// 检查是否已授权(同企业 / 其他企业)
if authEnterpriseID, exists := activeAuthEnterpriseMap[device.ID]; exists {
reason := "设备已授权给其他企业"
if authEnterpriseID == enterpriseID {
reason = "设备已授权给此企业"
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: reason,
})
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: reason})
continue
}
devicesToAllocate = append(devicesToAllocate, device)
devices = append(devices, device)
}
// 在事务中处理授权
if len(devicesToAllocate) > 0 {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
authorizerType := userType
// 1. 创建设备授权记录(逐条处理,避免并发冲突导致整批失败)
deviceAuthIDMap := make(map[uint]uint, len(devicesToAllocate))
successDevices := make([]*model.Device, 0, len(devicesToAllocate))
for _, device := range devicesToAllocate {
deviceAuth := &model.EnterpriseDeviceAuthorization{
EnterpriseID: enterpriseID,
DeviceID: device.ID,
AuthorizedBy: currentUserID,
AuthorizedAt: now,
AuthorizerType: authorizerType,
Remark: req.Remark,
}
if err := tx.Create(deviceAuth).Error; err != nil {
if isUniqueConstraintViolation(err, "uq_active_device_auth") {
reason := "设备已授权给其他企业"
var existingAuth model.EnterpriseDeviceAuthorization
queryErr := tx.Select("enterprise_id").
Where("device_id = ? AND revoked_at IS NULL", device.ID).
First(&existingAuth).Error
if queryErr == nil && existingAuth.EnterpriseID == enterpriseID {
reason = "设备已授权给此企业"
}
if queryErr != nil && !stderrors.Is(queryErr, gorm.ErrRecordNotFound) {
return errors.Wrap(errors.CodeInternalError, queryErr, "查询冲突授权记录失败")
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: device.VirtualNo,
Reason: reason,
})
continue
}
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
}
deviceAuthIDMap[device.ID] = deviceAuth.ID
successDevices = append(successDevices, device)
}
// 2. 查询所有设备绑定的卡
deviceIDsToQuery := make([]uint, 0, len(successDevices))
for _, device := range successDevices {
deviceIDsToQuery = append(deviceIDsToQuery, device.ID)
}
var bindings []model.DeviceSimBinding
if len(deviceIDsToQuery) > 0 {
if err := tx.Where("device_id IN ? AND bind_status = 1", deviceIDsToQuery).Find(&bindings).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
}
// 3. 为每张绑定的卡创建授权记录
if len(bindings) > 0 {
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
for _, binding := range bindings {
deviceAuthID := deviceAuthIDMap[binding.DeviceID]
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
EnterpriseID: enterpriseID,
CardID: binding.IotCardID,
DeviceAuthID: &deviceAuthID,
AuthorizedBy: currentUserID,
AuthorizedAt: now,
AuthorizerType: authorizerType,
Remark: req.Remark,
})
}
if err := tx.Create(cardAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
}
}
// 4. 统计每个设备的绑定卡数量
deviceCardCount := make(map[uint]int)
for _, binding := range bindings {
deviceCardCount[binding.DeviceID]++
}
// 5. 构建响应
for _, device := range successDevices {
resp.AuthorizedDevices = append(resp.AuthorizedDevices, dto.AuthorizedDeviceItem{
DeviceID: device.ID,
VirtualNo: device.VirtualNo,
CardCount: deviceCardCount[device.ID],
})
}
return nil
})
if err != nil {
return nil, err
}
}
resp.SuccessCount = len(resp.AuthorizedDevices)
resp.FailCount = len(resp.FailedItems)
return resp, nil
return devices
}
// allocateDevices 在同一事务内创建设备、随设备卡授权和统一审计事实。
func (s *Service) allocateDevices(
ctx context.Context,
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices []*model.Device,
req *dto.AllocateDevicesReq,
operatorID uint,
userType int,
resp *dto.AllocateDevicesResp,
) ([]dto.AuthorizedDeviceItem, error) {
items := make([]dto.AuthorizedDeviceItem, 0, len(devices))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", deviceIDs(devices)).Find(&[]model.Device{}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "锁定待授权设备失败")
}
now := time.Now()
deviceAuthByDevice := make(map[uint]*model.EnterpriseDeviceAuthorization, len(devices))
successDevices := make([]*model.Device, 0, len(devices))
for _, device := range devices {
auth := &model.EnterpriseDeviceAuthorization{
EnterpriseID: enterprise.ID, DeviceID: device.ID, AuthorizedBy: operatorID,
AuthorizedAt: now, AuthorizerType: userType, Remark: req.Remark,
}
if err := tx.Transaction(func(itemTx *gorm.DB) error { return itemTx.Create(auth).Error }); err != nil {
if !isUniqueConstraintViolation(err, "uq_active_device_auth") {
return errors.Wrap(errors.CodeInternalError, err, "创建设备授权记录失败")
}
reason, err := deviceAuthorizationConflictReason(tx, device.ID, enterprise.ID)
if err != nil {
return err
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: device.VirtualNo, Reason: reason})
continue
}
deviceAuthByDevice[device.ID] = auth
successDevices = append(successDevices, device)
}
if len(successDevices) == 0 {
return nil
}
successDeviceIDs := deviceIDs(successDevices)
bindings, err := loadDeviceBindings(tx, successDeviceIDs, true)
if err != nil {
return err
}
cardAuths := make([]*model.EnterpriseCardAuthorization, 0, len(bindings))
for _, binding := range bindings {
deviceAuthID := deviceAuthByDevice[binding.DeviceID].ID
cardAuths = append(cardAuths, &model.EnterpriseCardAuthorization{
EnterpriseID: enterprise.ID, CardID: binding.IotCardID, DeviceAuthID: &deviceAuthID,
AuthorizedBy: operatorID, AuthorizedAt: now, AuthorizerType: userType, Remark: req.Remark,
})
}
if len(cardAuths) > 0 {
if err := tx.Create(cardAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建卡授权记录失败")
}
}
cards, err := loadAuditCards(tx, cardAuths)
if err != nil {
return err
}
result := constants.AuditResultSuccess
if len(resp.FailedItems) > 0 {
result = constants.AuditResultPartial
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, enterpriseDeviceAllocateAudit(
enterprise, ownerShop, successDevices, deviceAuthByDevice, bindings, cards, cardAuths, operatorID, result,
)); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业设备授权审计失败")
}
cardCount := make(map[uint]int, len(successDevices))
for _, binding := range bindings {
cardCount[binding.DeviceID]++
}
for _, device := range successDevices {
items = append(items, dto.AuthorizedDeviceItem{
DeviceID: device.ID, VirtualNo: device.VirtualNo, CardCount: cardCount[device.ID],
})
}
return nil
})
return items, err
}
func deviceAuthorizationConflictReason(tx *gorm.DB, deviceID, enterpriseID uint) (string, error) {
reason := "设备已授权给其他企业"
var existing model.EnterpriseDeviceAuthorization
err := tx.Select("enterprise_id").Where("device_id = ? AND revoked_at IS NULL", deviceID).First(&existing).Error
if err == nil && existing.EnterpriseID == enterpriseID {
return "设备已授权给此企业", nil
}
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return "", errors.Wrap(errors.CodeInternalError, err, "查询冲突授权记录失败")
}
return reason, nil
}
// enterpriseDeviceAllocateAudit 装配企业、设备、卡槽、卡及授权记录的资源关系。
func enterpriseDeviceAllocateAudit(
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices []*model.Device,
deviceAuthByDevice map[uint]*model.EnterpriseDeviceAuthorization,
bindings []*model.DeviceSimBinding,
cards map[uint]*model.IotCard,
cardAuths []*model.EnterpriseCardAuthorization,
operatorID uint,
result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseDevicesAllocated, Summary: "向企业授权设备",
Result: result, OperatorID: operatorID, Enterprise: enterprise, Shop: ownerShop,
BeforeData: map[string]any{"authorized_device_count": 0},
AfterData: map[string]any{"authorized_device_count": len(devices)},
}
for _, device := range devices {
auth := deviceAuthByDevice[device.ID]
change.Devices = append(change.Devices, accessauditapp.DeviceChange{
Device: device, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备已授权给企业",
})
change.DeviceAuthorizations = append(change.DeviceAuthorizations, accessauditapp.EnterpriseDeviceAuthorizationChange{
Authorization: auth, AfterData: map[string]any{"authorized": true},
})
}
for _, binding := range bindings {
change.DeviceBindings = append(change.DeviceBindings, accessauditapp.DeviceSimBindingChange{Binding: binding})
}
for _, auth := range cardAuths {
card := cards[auth.CardID]
change.Cards = append(change.Cards, accessauditapp.IotCardChange{
Card: card, BeforeData: map[string]any{"enterprise_id": nil, "authorized": false},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡已随设备授权给企业",
})
change.CardAuthorizations = append(change.CardAuthorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth, AfterData: map[string]any{"authorized": true},
})
}
return change
}
// isUniqueConstraintViolation 判断 PostgreSQL 唯一约束冲突并可限定约束名称。
func isUniqueConstraintViolation(err error, constraintName string) bool {
var pgErr *pgconn.PgError
if stderrors.As(err, &pgErr) {
@@ -296,6 +365,9 @@ func (s *Service) resolveDeviceNosForAllocate(ctx context.Context, req *dto.Allo
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
if !canManageEnterpriseDevice(ctx, d) {
continue
}
nos = append(nos, d.VirtualNo)
}
return nos, nil
@@ -323,6 +395,9 @@ func (s *Service) resolveDeviceNosForRecall(ctx context.Context, enterpriseID ui
}
nos := make([]string, 0, len(devices))
for _, d := range devices {
if !canManageEnterpriseDevice(ctx, d) {
continue
}
nos = append(nos, d.VirtualNo)
}
return nos, nil
@@ -334,12 +409,27 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
if err := validateRecallDevicesRequest(req); err != nil {
return nil, err
}
// 验证企业存在
_, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "企业设备授权审计接缝未配置")
}
if err := validateEnterpriseDeviceActor(ctx); err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, err)
return nil, err
}
if err := middleware.CanManageEnterprise(ctx, enterpriseID, s.enterpriseStore); err != nil {
permissionErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", &model.Enterprise{ID: enterpriseID}, nil, nil, permissionErr)
return nil, permissionErr
}
enterprise, err := s.enterpriseStore.GetByID(ctx, enterpriseID)
if err != nil {
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
}
ownerShop := loadEnterpriseDeviceOwnerShop(ctx, s.db, enterprise.OwnerShopID)
// 根据选取模式解析候选设备号列表
deviceNos, err := s.resolveDeviceNosForRecall(ctx, enterpriseID, req)
@@ -349,7 +439,7 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
// 查询设备
var devices []model.Device
if err := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
if err := enterpriseDeviceQuery(ctx, s.db).Where("virtual_no IN ?", deviceNos).Find(&devices).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备信息失败")
}
@@ -370,66 +460,386 @@ func (s *Service) RecallDevices(ctx context.Context, enterpriseID uint, req *dto
FailedItems: make([]dto.FailedDeviceItem, 0),
}
deviceAuthsToRevoke := make([]uint, 0)
for _, deviceNo := range deviceNos {
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备不存在",
})
continue
}
if !existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "设备未授权给此企业",
})
continue
}
// 获取授权记录ID
auth, err := s.enterpriseDeviceAuthStore.GetByDeviceID(ctx, device.ID)
if err != nil || auth.EnterpriseID != enterpriseID {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: deviceNo,
Reason: "授权记录不存在",
})
continue
}
deviceAuthsToRevoke = append(deviceAuthsToRevoke, auth.ID)
}
// 在事务中处理撤销
if len(deviceAuthsToRevoke) > 0 {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 撤销设备授权
if err := s.enterpriseDeviceAuthStore.RevokeByIDs(ctx, deviceAuthsToRevoke, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
}
// 2. 级联撤销卡授权
for _, authID := range deviceAuthsToRevoke {
if err := s.enterpriseCardAuthStore.RevokeByDeviceAuthID(ctx, authID, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
}
}
return nil
})
deviceIDsToRecall := selectDeviceIDsForRecall(deviceNos, deviceMap, existingAuths, resp)
if len(deviceIDsToRecall) > 0 {
recalledIDs, err := s.recallDevices(ctx, enterprise, ownerShop, deviceIDsToRecall, deviceMap, currentUserID, len(resp.FailedItems) > 0)
if err != nil {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权失败", enterprise, ownerShop, devicePointers(deviceMap, deviceIDsToRecall), err)
return nil, err
}
recalled := make(map[uint]struct{}, len(recalledIDs))
for _, deviceID := range recalledIDs {
recalled[deviceID] = struct{}{}
}
devicesByID := devicesByID(deviceMap)
for _, deviceID := range deviceIDsToRecall {
if _, ok := recalled[deviceID]; ok {
continue
}
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{
VirtualNo: devicesByID[deviceID].VirtualNo,
Reason: "设备未授权给此企业",
})
}
resp.SuccessCount = len(recalledIDs)
}
resp.SuccessCount = len(deviceAuthsToRevoke)
resp.FailCount = len(resp.FailedItems)
if resp.SuccessCount == 0 {
s.recordDeviceFailure(ctx, constants.AuditActionEnterpriseDevicesRecalled, "回收企业设备授权被拒绝", enterprise, ownerShop, devicePointers(deviceMap, deviceIDs), errors.New(errors.CodeInvalidStatus, "没有设备满足回收条件"))
}
return resp, nil
}
// selectDeviceIDsForRecall 按当前有效授权筛选回收目标,并保持越权与不存在同错。
func selectDeviceIDsForRecall(
deviceNos []string,
deviceMap map[string]*model.Device,
existingAuths map[uint]bool,
resp *dto.RecallDevicesResp,
) []uint {
deviceIDs := make([]uint, 0, len(deviceNos))
seenDeviceNos := make(map[string]struct{}, len(deviceNos))
for _, deviceNo := range deviceNos {
if _, seen := seenDeviceNos[deviceNo]; seen {
continue
}
seenDeviceNos[deviceNo] = struct{}{}
device, exists := deviceMap[deviceNo]
if !exists {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "无权限操作该资源或资源不存在"})
continue
}
if !existingAuths[device.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedDeviceItem{VirtualNo: deviceNo, Reason: "设备未授权给此企业"})
continue
}
deviceIDs = append(deviceIDs, device.ID)
}
return deviceIDs
}
// recallDevices 在锁定有效授权后撤销实际命中项,并返回真实回收设备 ID。
func (s *Service) recallDevices(
ctx context.Context,
enterprise *model.Enterprise,
ownerShop *model.Shop,
requestedDeviceIDs []uint,
deviceMap map[string]*model.Device,
operatorID uint,
partial bool,
) ([]uint, error) {
recalledDeviceIDs := make([]uint, 0, len(requestedDeviceIDs))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var deviceAuths []*model.EnterpriseDeviceAuthorization
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("enterprise_id = ? AND device_id IN ? AND revoked_at IS NULL", enterprise.ID, requestedDeviceIDs).
Find(&deviceAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询有效设备授权失败")
}
if len(deviceAuths) == 0 {
return nil
}
authIDs := make([]uint, 0, len(deviceAuths))
actualDeviceIDs := make([]uint, 0, len(deviceAuths))
for _, auth := range deviceAuths {
authIDs = append(authIDs, auth.ID)
actualDeviceIDs = append(actualDeviceIDs, auth.DeviceID)
}
bindings, err := loadDeviceBindings(tx, actualDeviceIDs, true)
if err != nil {
return err
}
var cardAuths []*model.EnterpriseCardAuthorization
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("device_auth_id IN ? AND revoked_at IS NULL", authIDs).
Find(&cardAuths).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询有效卡授权失败")
}
cards, err := loadAuditCards(tx, cardAuths)
if err != nil {
return err
}
now := time.Now()
if err := tx.Model(&model.EnterpriseDeviceAuthorization{}).
Where("id IN ? AND revoked_at IS NULL", authIDs).
Updates(map[string]any{"revoked_by": operatorID, "revoked_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销设备授权失败")
}
if len(cardAuths) > 0 {
cardAuthIDs := make([]uint, 0, len(cardAuths))
for _, auth := range cardAuths {
cardAuthIDs = append(cardAuthIDs, auth.ID)
}
if err := tx.Model(&model.EnterpriseCardAuthorization{}).
Where("id IN ? AND revoked_at IS NULL", cardAuthIDs).
Updates(map[string]any{"revoked_by": operatorID, "revoked_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "撤销卡授权失败")
}
}
devicesByID := devicesByID(deviceMap)
result := constants.AuditResultSuccess
if partial || len(deviceAuths) < len(requestedDeviceIDs) {
result = constants.AuditResultPartial
}
change := enterpriseDeviceRecallAudit(
enterprise, ownerShop, devicesByID, deviceAuths, bindings, cards, cardAuths, operatorID, now, result,
)
if err := s.accessAudit.WriteAccessChange(ctx, tx, change); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入企业设备回收审计失败")
}
recalledDeviceIDs = actualDeviceIDs
return nil
})
return recalledDeviceIDs, err
}
// enterpriseDeviceRecallAudit 装配回收操作涉及的设备、卡槽、卡和授权记录变化。
func enterpriseDeviceRecallAudit(
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices map[uint]*model.Device,
deviceAuths []*model.EnterpriseDeviceAuthorization,
bindings []*model.DeviceSimBinding,
cards map[uint]*model.IotCard,
cardAuths []*model.EnterpriseCardAuthorization,
operatorID uint,
now time.Time,
result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionEnterpriseDevicesRecalled, Summary: "回收企业设备授权",
Result: result, OperatorID: operatorID, Enterprise: enterprise, Shop: ownerShop,
BeforeData: map[string]any{"authorized_device_count": len(deviceAuths)},
AfterData: map[string]any{"authorized_device_count": 0},
}
for _, auth := range deviceAuths {
device := devices[auth.DeviceID]
change.Devices = append(change.Devices, accessauditapp.DeviceChange{
Device: device,
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备授权已回收",
})
beforeRevokedBy, beforeRevokedAt := auth.RevokedBy, auth.RevokedAt
auth.RevokedBy, auth.RevokedAt = &operatorID, &now
change.DeviceAuthorizations = append(change.DeviceAuthorizations, accessauditapp.EnterpriseDeviceAuthorizationChange{
Authorization: auth,
BeforeData: map[string]any{"revoked_by": beforeRevokedBy, "revoked_at": beforeRevokedAt},
AfterData: map[string]any{"revoked_by": operatorID, "revoked_at": now},
})
}
for _, binding := range bindings {
change.DeviceBindings = append(change.DeviceBindings, accessauditapp.DeviceSimBindingChange{Binding: binding})
}
for _, auth := range cardAuths {
card := cards[auth.CardID]
change.Cards = append(change.Cards, accessauditapp.IotCardChange{
Card: card,
BeforeData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": true},
AfterData: map[string]any{"enterprise_id": enterprise.ID, "authorization_id": auth.ID, "authorized": false},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "卡授权已随设备回收",
})
beforeRevokedBy, beforeRevokedAt := auth.RevokedBy, auth.RevokedAt
auth.RevokedBy, auth.RevokedAt = &operatorID, &now
change.CardAuthorizations = append(change.CardAuthorizations, accessauditapp.EnterpriseCardAuthorizationChange{
Authorization: auth,
BeforeData: map[string]any{"revoked_by": beforeRevokedBy, "revoked_at": beforeRevokedAt},
AfterData: map[string]any{"revoked_by": operatorID, "revoked_at": now},
})
}
return change
}
func validateEnterpriseDeviceActor(ctx context.Context) error {
switch middleware.GetUserTypeFromContext(ctx) {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent:
return nil
default:
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
}
// validateAllocateDevicesRequest 校验 Service 边界的授权选取条件,防止空筛选扩散为全量操作。
func validateAllocateDevicesRequest(req *dto.AllocateDevicesReq) error {
if req == nil || utf8.RuneCountInString(req.VirtualNo) > 100 || utf8.RuneCountInString(req.BatchNo) > 100 || utf8.RuneCountInString(req.Remark) > 500 {
return errors.New(errors.CodeInvalidParam)
}
if req.SelectionType == "filter" {
if req.VirtualNo == "" && req.BatchNo == "" && req.ShopID == nil {
return errors.New(errors.CodeInvalidParam)
}
if req.ShopID != nil && *req.ShopID == 0 {
return errors.New(errors.CodeInvalidParam)
}
return nil
}
if req.SelectionType != "list" || len(req.DeviceNos) == 0 || len(req.DeviceNos) > 100 {
return errors.New(errors.CodeInvalidParam)
}
for _, deviceNo := range req.DeviceNos {
if deviceNo == "" {
return errors.New(errors.CodeInvalidParam)
}
}
return nil
}
// validateRecallDevicesRequest 校验 Service 边界的回收选取条件,防止空筛选扩散为全量操作。
func validateRecallDevicesRequest(req *dto.RecallDevicesReq) error {
if req == nil || utf8.RuneCountInString(req.VirtualNo) > 100 || utf8.RuneCountInString(req.BatchNo) > 100 {
return errors.New(errors.CodeInvalidParam)
}
if req.SelectionType == "filter" {
if req.VirtualNo == "" && req.BatchNo == "" {
return errors.New(errors.CodeInvalidParam)
}
return nil
}
if req.SelectionType != "list" || len(req.DeviceNos) == 0 || len(req.DeviceNos) > 100 {
return errors.New(errors.CodeInvalidParam)
}
for _, deviceNo := range req.DeviceNos {
if deviceNo == "" {
return errors.New(errors.CodeInvalidParam)
}
}
return nil
}
func enterpriseDeviceQuery(ctx context.Context, db *gorm.DB) *gorm.DB {
query := db.WithContext(ctx).Model(&model.Device{})
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return query
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return query.Where("1 = 0")
}
return query.Where("shop_id = ?", shopID)
}
func canManageEnterpriseDevice(ctx context.Context, device *model.Device) bool {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return true
}
shopID := middleware.GetShopIDFromContext(ctx)
return shopID > 0 && device.ShopID != nil && *device.ShopID == shopID
}
func loadEnterpriseDeviceOwnerShop(ctx context.Context, db *gorm.DB, ownerShopID *uint) *model.Shop {
if ownerShopID == nil {
return nil
}
var shop model.Shop
if err := db.WithContext(ctx).Unscoped().First(&shop, *ownerShopID).Error; err != nil {
return nil
}
return &shop
}
func loadDeviceBindings(tx *gorm.DB, deviceIDs []uint, lock bool) ([]*model.DeviceSimBinding, error) {
bindings := make([]*model.DeviceSimBinding, 0)
if len(deviceIDs) == 0 {
return bindings, nil
}
query := tx.Where("device_id IN ? AND bind_status = 1", deviceIDs)
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := query.Find(&bindings).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
}
return bindings, nil
}
// loadAuditCards 使用非作用域查询保留软删除卡的稳定审计身份快照。
func loadAuditCards(tx *gorm.DB, auths []*model.EnterpriseCardAuthorization) (map[uint]*model.IotCard, error) {
cardIDs := make([]uint, 0, len(auths))
for _, auth := range auths {
cardIDs = append(cardIDs, auth.CardID)
}
cards := make(map[uint]*model.IotCard, len(cardIDs))
if len(cardIDs) == 0 {
return cards, nil
}
var values []*model.IotCard
if err := tx.Unscoped().Where("id IN ?", cardIDs).Find(&values).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡审计快照失败")
}
for _, card := range values {
cards[card.ID] = card
}
return cards, nil
}
func deviceIDs(devices []*model.Device) []uint {
ids := make([]uint, 0, len(devices))
for _, device := range devices {
ids = append(ids, device.ID)
}
return ids
}
func devicesByID(deviceMap map[string]*model.Device) map[uint]*model.Device {
result := make(map[uint]*model.Device, len(deviceMap))
for _, device := range deviceMap {
result[device.ID] = device
}
return result
}
func devicePointers(deviceMap map[string]*model.Device, ids []uint) []*model.Device {
wanted := make(map[uint]struct{}, len(ids))
for _, id := range ids {
wanted[id] = struct{}{}
}
devices := make([]*model.Device, 0, len(ids))
for _, device := range deviceMap {
if _, ok := wanted[device.ID]; ok {
devices = append(devices, device)
}
}
return devices
}
// recordDeviceFailure 在业务事务结束后使用统一 Writer 记录失败或拒绝事实。
func (s *Service) recordDeviceFailure(
ctx context.Context,
actionCode, summary string,
enterprise *model.Enterprise,
ownerShop *model.Shop,
devices []*model.Device,
originalErr error,
) {
changes := make([]accessauditapp.DeviceChange, 0, len(devices))
for _, device := range devices {
changes = append(changes, accessauditapp.DeviceChange{Device: device})
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: enterpriseDeviceFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Enterprise: enterprise, Shop: ownerShop,
Devices: changes, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
func enterpriseDeviceFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeEnterpriseNotFound:
return constants.AuditResultDenied
case errors.CodeInvalidStatus:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// ListDevices 查询企业授权设备列表(后台管理)
func (s *Service) ListDevices(ctx context.Context, enterpriseID uint, req *dto.EnterpriseDeviceListReq) (*dto.EnterpriseDeviceListResp, error) {
// 验证企业存在

View File

@@ -52,6 +52,7 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
ResourceKey: &card.ICCID,
RequestID: requestID,
CorrelationID: requestID,
TriggerSeries: requestID,
RequestSummary: map[string]any{
"iot_card_id": card.ID,
"iccid": card.ICCID,
@@ -70,6 +71,13 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d
CardNo: card.ICCID,
Code: strconv.Itoa(*code),
})
if gatewayErr != nil && s.logger != nil {
s.logger.Warn("Gateway 卡限速请求失败",
zap.Uint("iot_card_id", card.ID),
zap.String("integration_id", attempt.IntegrationID),
zap.Error(gatewayErr),
)
}
completion := speedTierCompletion(gatewayErr, time.Since(startedAt))
if _, completeErr := s.speedTierIntegration.Complete(ctx, attempt.IntegrationID, completion); completeErr != nil {
if s.logger != nil {
@@ -120,7 +128,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl
}
completion.Result = constants.IntegrationResultFailed
completion.StateChanged = false
completion.ProviderMessage = err.Error()
completion.SafeProviderMessage = "Gateway 卡限速请求失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(err) {
completion.Result = constants.IntegrationResultUnknown

View File

@@ -8,6 +8,7 @@ import (
"regexp"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -28,6 +29,8 @@ type AccountServiceInterface interface {
// Service 权限业务服务
type Service struct {
db *gorm.DB
accessAudit accessauditapp.Writer
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
@@ -35,6 +38,12 @@ type Service struct {
redisClient *redis.Client
}
// SetAccessAudit 注入权限定义变更的事务审计接缝。
func (s *Service) SetAccessAudit(db *gorm.DB, writer accessauditapp.Writer) {
s.db = db
s.accessAudit = writer
}
// New 创建权限服务
func New(
permissionStore *postgres.PermissionStore,
@@ -60,26 +69,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePermissionRequest)
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 验证权限编码格式
if !permCodeRegex.MatchString(req.PermCode) {
return nil, errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
}
// 检查权限编码唯一性
existing, err := s.permissionStore.GetByCode(ctx, req.PermCode)
if err == nil && existing != nil {
return nil, errors.New(errors.CodePermCodeExists, "权限编码已存在")
}
// 验证 parent_id 存在(如果提供)
if req.ParentID != nil {
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
if err != nil || parent == nil {
return nil, errors.New(errors.CodeNotFound, "上级权限不存在")
}
}
// 创建权限
permission := &model.Permission{
PermName: req.PermName,
PermCode: req.PermCode,
@@ -89,14 +78,59 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePermissionRequest)
ParentID: req.ParentID,
Sort: req.Sort,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: currentUserID,
Updater: currentUserID,
},
}
// 如果未指定 platform默认为 all
if permission.Platform == "" {
permission.Platform = constants.PlatformAll
}
if err := s.permissionStore.Create(ctx, permission); err != nil {
// 验证权限编码格式
if !permCodeRegex.MatchString(req.PermCode) {
appErr := errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建非法权限编码", constants.AuditResultDenied, permission, nil, appErr)
return nil, appErr
}
// 检查权限编码唯一性
existing, err := s.permissionStore.GetByCode(ctx, req.PermCode)
if err == nil && existing != nil {
appErr := errors.New(errors.CodePermCodeExists, "权限编码已存在")
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建重复权限编码", constants.AuditResultDenied, permission, nil, appErr)
return nil, appErr
}
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查权限编码失败")
}
// 验证 parent_id 存在(如果提供)
if req.ParentID != nil {
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查上级权限失败")
}
if err == gorm.ErrRecordNotFound || parent == nil {
appErr := errors.New(errors.CodeNotFound, "上级权限不存在")
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "拒绝创建上级不存在的权限", constants.AuditResultDenied, permission, nil, appErr)
return nil, appErr
}
}
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewPermissionStore(tx).Create(ctx, permission); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPermissionCreated, Summary: "创建权限", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Permissions: permissionChanges(permission, nil, permissionAuditData(permission)),
})
}); err != nil {
permission.ID = 0
s.recordFailure(ctx, constants.AuditActionPermissionCreated, "创建权限失败", constants.AuditResultFailed, permission, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建权限失败")
}
@@ -131,6 +165,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
}
beforeData := permissionAuditData(permission)
// 更新字段
if req.PermName != nil {
@@ -139,12 +174,20 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
if req.PermCode != nil {
// 验证权限编码格式
if !permCodeRegex.MatchString(*req.PermCode) {
return nil, errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
appErr := errors.New(errors.CodeInvalidPermCode, "权限编码格式不正确(应为 module:action 格式)")
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新非法权限编码", constants.AuditResultDenied, permission, beforeData, appErr)
return nil, appErr
}
// 检查新权限编码唯一性
existing, err := s.permissionStore.GetByCode(ctx, *req.PermCode)
if err == nil && existing != nil && existing.ID != id {
return nil, errors.New(errors.CodePermCodeExists, "权限编码已存在")
appErr := errors.New(errors.CodePermCodeExists, "权限编码已存在")
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新重复权限编码", constants.AuditResultDenied, permission, beforeData, appErr)
return nil, appErr
}
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查权限编码失败")
}
permission.PermCode = *req.PermCode
}
@@ -157,8 +200,14 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
if req.ParentID != nil {
// 验证 parent_id 存在
parent, err := s.permissionStore.GetByID(ctx, *req.ParentID)
if err != nil || parent == nil {
return nil, errors.New(errors.CodeNotFound, "上级权限不存在")
if err != nil && err != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查上级权限失败")
}
if err == gorm.ErrRecordNotFound || parent == nil {
appErr := errors.New(errors.CodeNotFound, "上级权限不存在")
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "拒绝更新不存在的上级权限", constants.AuditResultDenied, permission, beforeData, appErr)
return nil, appErr
}
permission.ParentID = req.ParentID
}
@@ -171,9 +220,25 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
permission.Updater = currentUserID
if err := s.permissionStore.Update(ctx, permission); err != nil {
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewPermissionStore(tx).Update(ctx, permission); err != nil {
return err
}
var err error
accountIDs, err = permissionCacheAccountIDs(ctx, tx, permission.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPermissionUpdated, Summary: "更新权限", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Permissions: permissionChanges(permission, beforeData, permissionAuditData(permission)),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionPermissionUpdated, "更新权限失败", constants.AuditResultFailed, permission, beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新权限失败")
}
s.clearPermissionCaches(ctx, accountIDs)
return permission, nil
}
@@ -181,7 +246,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePermission
// Delete 软删除权限
func (s *Service) Delete(ctx context.Context, id uint) error {
// 检查权限存在
_, err := s.permissionStore.GetByID(ctx, id)
permission, err := s.permissionStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodePermissionNotFound, "权限不存在")
@@ -189,9 +254,27 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
return errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
}
if err := s.permissionStore.Delete(ctx, id); err != nil {
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := permissionAuditData(permission)
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewPermissionStore(tx).Delete(ctx, id); err != nil {
return err
}
var err error
accountIDs, err = permissionCacheAccountIDs(ctx, tx, permission.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPermissionDeleted, Summary: "删除权限", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Permissions: permissionChanges(permission, beforeData, map[string]any{"deleted": true}),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionPermissionDeleted, "删除权限失败", constants.AuditResultFailed, permission, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "删除权限失败")
}
s.clearPermissionCaches(ctx, accountIDs)
return nil
}
@@ -351,3 +434,70 @@ func (s *Service) matchPermission(permissions []permissionCacheItem, permCode st
}
return false
}
func (s *Service) runAccessTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "权限审计接缝未配置")
}
return s.db.WithContext(ctx).Transaction(fn)
}
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary, result string,
permission *model.Permission,
beforeData map[string]any,
originalErr error,
) {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: middleware.GetUserIDFromContext(ctx),
Permissions: permissionChanges(permission, beforeData, nil),
}, originalErr)
}
func permissionChanges(permission *model.Permission, beforeData, afterData map[string]any) []accessauditapp.PermissionChange {
if permission == nil {
return nil
}
return []accessauditapp.PermissionChange{{Permission: permission, BeforeData: beforeData, AfterData: afterData}}
}
func permissionAuditData(permission *model.Permission) map[string]any {
if permission == nil {
return nil
}
return map[string]any{
"perm_name": permission.PermName, "perm_code": permission.PermCode, "perm_type": permission.PermType,
"platform": permission.Platform, "available_for_role_types": permission.AvailableForRoleTypes,
"url": permission.URL, "parent_id": permission.ParentID, "sort": permission.Sort, "status": permission.Status,
}
}
func permissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, permissionID uint) ([]uint, error) {
var roleIDs []uint
if err := tx.WithContext(ctx).Model(&model.RolePermission{}).
Where("perm_id = ?", permissionID).Pluck("role_id", &roleIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询权限关联角色失败")
}
if len(roleIDs) == 0 {
return nil, nil
}
var accountIDs []uint
if err := tx.WithContext(ctx).Model(&model.AccountRole{}).
Where("role_id IN ?", roleIDs).Distinct().Pluck("account_id", &accountIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询权限关联账号失败")
}
return accountIDs, nil
}
func (s *Service) clearPermissionCaches(ctx context.Context, accountIDs []uint) {
if len(accountIDs) == 0 || s.redisClient == nil {
return
}
pipe := s.redisClient.Pipeline()
for _, accountID := range accountIDs {
pipe.Del(ctx, constants.RedisUserPermissionsKey(accountID))
}
_, _ = pipe.Exec(ctx)
}

View File

@@ -3,59 +3,88 @@ package personal_customer
import (
"context"
stderrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Service 个人客户服务
type Service struct {
store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
logger *zap.Logger
db *gorm.DB
store *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
logger *zap.Logger
accessAudit accessauditapp.Writer
}
// NewService 创建个人客户服务实例
func NewService(
db *gorm.DB,
store *postgres.PersonalCustomerStore,
phoneStore *postgres.PersonalCustomerPhoneStore,
logger *zap.Logger,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
store: store,
phoneStore: phoneStore,
logger: logger,
db: db,
store: store,
phoneStore: phoneStore,
logger: logger,
accessAudit: accessAudit,
}
}
// UpdateProfile 更新个人资料
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
customer, err := s.store.GetByID(ctx, customerID)
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
customer := &model.PersonalCustomer{Model: gorm.Model{ID: customerID}}
failureCustomer := customer
var beforeData map[string]any
loaded := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
loaded = true
beforeData = personalProfileAuditData(customer)
current := *customer
failureCustomer = &current
if nickname != "" {
customer.Nickname = nickname
}
if avatarURL != "" {
customer.AvatarURL = avatarURL
}
if err := tx.Save(customer).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
}
return s.accessAudit.WriteAccessChange(ctx, tx, personalProfileAudit(customer, beforeData, constants.AuditResultSuccess))
})
if err != nil {
s.logger.Error("查询个人客户失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
// 更新资料
if nickname != "" {
customer.Nickname = nickname
}
if avatarURL != "" {
customer.AvatarURL = avatarURL
}
if err := s.store.Update(ctx, customer); err != nil {
if !loaded {
s.logger.Error("查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err))
return err
}
s.logger.Error("更新个人资料失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "更新个人资料失败")
failure := personalProfileAudit(failureCustomer, beforeData, personalAuditFailureResult(err))
failure.Summary = "更新个人资料失败"
failure.SubjectSummary = "个人资料更新失败"
failure.SubjectData = nil
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, failure, err)
return err
}
s.logger.Info("更新个人资料成功",
@@ -65,6 +94,32 @@ func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname,
return nil
}
func personalProfileAudit(customer *model.PersonalCustomer, beforeData map[string]any, result string) accessauditapp.ChangeAudit {
return accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "更新个人资料", Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalProfileAuditData(customer),
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已更新",
SubjectData: map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL},
}
}
func personalProfileAuditData(customer *model.PersonalCustomer) map[string]any {
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
}
func personalAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// GetProfileWithPhone 获取个人资料(包含主手机号)
func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*model.PersonalCustomer, string, error) {
// 获取客户信息

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -15,11 +16,15 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Service 角色业务服务
type Service struct {
db *gorm.DB
redisClient *redis.Client
accessAudit accessauditapp.Writer
roleStore *postgres.RoleStore
permissionStore *postgres.PermissionStore
rolePermissionStore *postgres.RolePermissionStore
@@ -27,6 +32,13 @@ type Service struct {
shopRoleStore *postgres.ShopRoleStore
}
// SetAccessAudit 注入角色与权限配置的事务审计接缝。
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
s.db = db
s.redisClient = redisClient
s.accessAudit = writer
}
// New 创建角色服务
func New(roleStore *postgres.RoleStore, permissionStore *postgres.PermissionStore, rolePermissionStore *postgres.RolePermissionStore, accountRoleStore *postgres.AccountRoleStore, shopRoleStore *postgres.ShopRoleStore) *Service {
return &Service{
@@ -46,24 +58,40 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRoleRequest) (*dto.
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 检查角色名是否已存在
exists, err := s.roleStore.ExistsByName(ctx, req.RoleName, 0)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
}
if exists {
return nil, errors.New(errors.CodeRoleNameExists)
}
// 创建角色
role := &model.Role{
RoleName: req.RoleName,
RoleDesc: req.RoleDesc,
RoleType: req.RoleType,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: currentUserID,
Updater: currentUserID,
},
}
if err := s.roleStore.Create(ctx, role); err != nil {
// 检查角色名是否已存在
exists, err := s.roleStore.ExistsByName(ctx, req.RoleName, 0)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleCreated, "创建角色失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
}
if exists {
appErr := errors.New(errors.CodeRoleNameExists)
s.recordFailure(ctx, constants.AuditActionRoleCreated, "拒绝创建重复角色", constants.AuditResultDenied, role, nil, nil, appErr)
return nil, appErr
}
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Create(ctx, role); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleCreated, Summary: "创建角色", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, AfterData: roleAuditData(role),
})
}); err != nil {
role.ID = 0
s.recordFailure(ctx, constants.AuditActionRoleCreated, "创建角色失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色失败")
}
@@ -98,15 +126,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
}
beforeData := roleAuditData(role)
// 如果修改了角色名,检查是否与其他角色重复
if req.RoleName != nil && *req.RoleName != role.RoleName {
exists, err := s.roleStore.ExistsByName(ctx, *req.RoleName, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "检查角色名失败")
}
if exists {
return nil, errors.New(errors.CodeRoleNameExists)
appErr := errors.New(errors.CodeRoleNameExists)
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "拒绝更新重复角色名", constants.AuditResultDenied, role, beforeData, nil, appErr)
return nil, appErr
}
role.RoleName = *req.RoleName
}
@@ -121,7 +153,16 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
role.Updater = currentUserID
if err := s.roleStore.Update(ctx, role); err != nil {
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Update(ctx, role); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleUpdated, Summary: "更新角色", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleUpdated, "更新角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新角色失败")
}
@@ -130,7 +171,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateRoleReques
// Delete 软删除角色
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.roleStore.GetByID(ctx, id)
role, err := s.roleStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -140,19 +181,34 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
if accountCount > 0 || shopCount > 0 {
return errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再删除", accountCount, shopCount))
appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再删除", accountCount, shopCount))
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "拒绝删除使用中的角色", constants.AuditResultDenied, role, roleAuditData(role), nil, appErr)
return appErr
}
if err := s.roleStore.Delete(ctx, id); err != nil {
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := roleAuditData(role)
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Delete(ctx, id); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDeleted, Summary: "删除角色", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: role, BeforeData: beforeData, AfterData: map[string]any{"deleted": true},
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleDeleted, "删除角色失败", constants.AuditResultFailed, role, beforeData, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "删除角色失败")
}
@@ -212,11 +268,14 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
permissions, err := s.permissionStore.GetByIDs(ctx, permIDs)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "获取权限失败")
}
if len(permissions) != len(permIDs) {
return nil, errors.New(errors.CodePermissionNotFound, "部分权限不存在")
appErr := errors.New(errors.CodePermissionNotFound, "部分权限不存在")
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "拒绝分配不存在的权限", constants.AuditResultDenied, role, nil, nil, appErr)
return nil, appErr
}
roleTypeStr := fmt.Sprintf("%d", role.RoleType)
@@ -228,12 +287,15 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
}
if len(invalidPermIDs) > 0 {
return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
appErr := errors.New(errors.CodeInvalidParam, fmt.Sprintf("权限 %v 不适用于此角色类型", invalidPermIDs))
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "拒绝分配不适用的权限", constants.AuditResultDenied, role, permissionAuditChanges(permissions, nil, nil), nil, appErr)
return nil, appErr
}
// 批量获取已有权限集合,避免逐条 Exists 查询
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "获取已有权限失败")
}
existingSet := make(map[uint]bool, len(existingPermIDs))
@@ -242,6 +304,11 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
}
var rps []*model.RolePermission
changedPermissions := make([]*model.Permission, 0, len(permissions))
permissionByID := make(map[uint]*model.Permission, len(permissions))
for _, permission := range permissions {
permissionByID[permission.ID] = permission
}
for _, permID := range permIDs {
if existingSet[permID] {
continue
@@ -252,11 +319,37 @@ func (s *Service) AssignPermissions(ctx context.Context, roleID uint, permIDs []
PermID: permID,
Status: constants.StatusEnabled,
}
if err := s.rolePermissionStore.Create(ctx, rp); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色-权限关联失败")
}
rps = append(rps, rp)
changedPermissions = append(changedPermissions, permissionByID[permID])
}
beforeData := map[string]any{"permission_ids": existingPermIDs}
afterData := map[string]any{"permission_ids": appendPermissionIDs(existingPermIDs, rps)}
if len(rps) == 0 {
return rps, nil
}
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
store := postgres.NewRolePermissionStore(tx, nil)
for _, rp := range rps {
if err := store.Create(ctx, rp); err != nil {
return err
}
}
var err error
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRolePermissionsAssigned, Summary: "分配角色权限", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, Permissions: permissionAuditChanges(changedPermissions, map[string]any{"assigned": false}, map[string]any{"assigned": true}),
BeforeData: beforeData, AfterData: afterData,
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsAssigned, "分配角色权限失败", constants.AuditResultFailed, role, permissionAuditChanges(changedPermissions, map[string]any{"assigned": false}, nil), beforeData, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建角色-权限关联失败")
}
s.clearRolePermissionCaches(ctx, accountIDs)
return rps, nil
}
@@ -288,7 +381,7 @@ func (s *Service) GetPermissions(ctx context.Context, roleID uint) ([]*model.Per
// RemovePermission 移除角色的权限
func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) error {
_, err := s.roleStore.GetByID(ctx, roleID)
role, err := s.roleStore.GetByID(ctx, roleID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -296,16 +389,52 @@ func (s *Service) RemovePermission(ctx context.Context, roleID, permID uint) err
return errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
}
if err := s.rolePermissionStore.Delete(ctx, roleID, permID); err != nil {
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "获取角色权限失败")
}
if !containsUint(existingPermIDs, permID) {
return nil
}
permission, permissionErr := s.permissionStore.GetByID(ctx, permID)
if permissionErr != nil && permissionErr != gorm.ErrRecordNotFound {
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, nil, map[string]any{"permission_ids": existingPermIDs}, permissionErr)
return errors.Wrap(errors.CodeInternalError, permissionErr, "获取待移除权限失败")
}
changes := []accessauditapp.PermissionChange(nil)
if permissionErr == nil {
changes = permissionAuditChanges([]*model.Permission{permission}, map[string]any{"assigned": true}, map[string]any{"assigned": false})
}
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := map[string]any{"permission_ids": existingPermIDs}
afterData := map[string]any{"permission_ids": removePermissionIDs(existingPermIDs, map[uint]struct{}{permID: {}})}
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRolePermissionStore(tx, nil).Delete(ctx, roleID, permID); err != nil {
return err
}
var err error
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRolePermissionRemoved, Summary: "移除角色权限", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: role, Permissions: changes, BeforeData: beforeData, AfterData: afterData,
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionRemoved, "移除角色权限失败", constants.AuditResultFailed, role, changes, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "删除角色-权限关联失败")
}
s.clearRolePermissionCaches(ctx, accountIDs)
return nil
}
// BatchRemovePermissions 批量移除角色的权限
func (s *Service) BatchRemovePermissions(ctx context.Context, roleID uint, permIDs []uint) error {
_, err := s.roleStore.GetByID(ctx, roleID)
role, err := s.roleStore.GetByID(ctx, roleID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound, "角色不存在")
@@ -313,9 +442,50 @@ func (s *Service) BatchRemovePermissions(ctx context.Context, roleID uint, permI
return errors.Wrap(errors.CodeInternalError, err, "获取角色失败")
}
if err := s.rolePermissionStore.BatchDelete(ctx, roleID, permIDs); err != nil {
existingPermIDs, err := s.rolePermissionStore.GetPermIDsByRoleID(ctx, roleID)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, nil, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "获取角色权限失败")
}
removeSet := make(map[uint]struct{}, len(permIDs))
actualIDs := make([]uint, 0, len(permIDs))
for _, permID := range permIDs {
removeSet[permID] = struct{}{}
if containsUint(existingPermIDs, permID) {
actualIDs = append(actualIDs, permID)
}
}
if len(actualIDs) == 0 {
return nil
}
permissions, err := s.permissionStore.GetByIDs(ctx, actualIDs)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, nil, map[string]any{"permission_ids": existingPermIDs}, err)
return errors.Wrap(errors.CodeInternalError, err, "获取待移除权限失败")
}
changes := permissionAuditChanges(permissions, map[string]any{"assigned": true}, map[string]any{"assigned": false})
operatorID := middleware.GetUserIDFromContext(ctx)
beforeData := map[string]any{"permission_ids": existingPermIDs}
afterData := map[string]any{"permission_ids": removePermissionIDs(existingPermIDs, removeSet)}
var accountIDs []uint
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRolePermissionStore(tx, nil).BatchDelete(ctx, roleID, permIDs); err != nil {
return err
}
var err error
accountIDs, err = rolePermissionCacheAccountIDs(ctx, tx, role.ID)
if err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRolePermissionsBatchRemoved, Summary: "批量移除角色权限", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: role, Permissions: changes, BeforeData: beforeData, AfterData: afterData,
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRolePermissionsBatchRemoved, "批量移除角色权限失败", constants.AuditResultFailed, role, changes, beforeData, err)
return errors.Wrap(errors.CodeInternalError, err, "批量删除角色-权限关联失败")
}
s.clearRolePermissionCaches(ctx, accountIDs)
return nil
}
@@ -340,23 +510,37 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
if status == constants.StatusDisabled {
accountCount, err := s.accountRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
shopCount, err := s.shopRoleStore.CountByRoleID(ctx, id)
if err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, roleAuditData(role), nil, err)
return errors.Wrap(errors.CodeInternalError, err, "检查角色分配情况失败")
}
if accountCount > 0 || shopCount > 0 {
return errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再禁用", accountCount, shopCount))
appErr := errors.New(errors.CodeRoleInUse, fmt.Sprintf("该角色已分配给 %d 个账号、%d 个店铺,请先移除相关分配后再禁用", accountCount, shopCount))
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "拒绝禁用使用中的角色", constants.AuditResultDenied, role, roleAuditData(role), nil, appErr)
return appErr
}
}
beforeData := roleAuditData(role)
role.Status = status
role.Updater = currentUserID
if err := s.roleStore.Update(ctx, role); err != nil {
if err := s.runAccessTransaction(ctx, func(tx *gorm.DB) error {
if err := postgres.NewRoleStore(tx).Update(ctx, role); err != nil {
return err
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleStatusUpdated, Summary: "更新角色状态", Result: constants.AuditResultSuccess,
OperatorID: currentUserID, Role: role, BeforeData: beforeData, AfterData: roleAuditData(role),
})
}); err != nil {
s.recordFailure(ctx, constants.AuditActionRoleStatusUpdated, "更新角色状态失败", constants.AuditResultFailed, role, beforeData, nil, err)
return errors.Wrap(errors.CodeInternalError, err, "更新角色状态失败")
}
@@ -390,3 +574,95 @@ func contains(availableForRoleTypes, roleTypeStr string) bool {
}
return false
}
func (s *Service) runAccessTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "角色权限审计接缝未配置")
}
return s.db.WithContext(ctx).Transaction(fn)
}
func (s *Service) recordFailure(
ctx context.Context,
actionCode, summary, result string,
role *model.Role,
permissions []accessauditapp.PermissionChange,
beforeData map[string]any,
originalErr error,
) {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: middleware.GetUserIDFromContext(ctx), Role: role, Permissions: permissions,
BeforeData: beforeData,
}, originalErr)
}
func roleAuditData(role *model.Role) map[string]any {
if role == nil {
return nil
}
return map[string]any{
"role_name": role.RoleName, "role_desc": role.RoleDesc, "role_type": role.RoleType, "status": role.Status,
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
}
}
func permissionAuditChanges(permissions []*model.Permission, beforeData, afterData map[string]any) []accessauditapp.PermissionChange {
changes := make([]accessauditapp.PermissionChange, 0, len(permissions))
for _, permission := range permissions {
if permission == nil {
continue
}
changes = append(changes, accessauditapp.PermissionChange{
Permission: permission, BeforeData: beforeData, AfterData: afterData,
})
}
return changes
}
func appendPermissionIDs(existing []uint, additions []*model.RolePermission) []uint {
result := append([]uint(nil), existing...)
for _, addition := range additions {
result = append(result, addition.PermID)
}
return result
}
func removePermissionIDs(existing []uint, removeSet map[uint]struct{}) []uint {
result := make([]uint, 0, len(existing))
for _, permissionID := range existing {
if _, removed := removeSet[permissionID]; !removed {
result = append(result, permissionID)
}
}
return result
}
func containsUint(values []uint, target uint) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func rolePermissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, roleID uint) ([]uint, error) {
var accountIDs []uint
if err := tx.WithContext(ctx).Model(&model.AccountRole{}).
Where("role_id = ?", roleID).Distinct().Pluck("account_id", &accountIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色关联账号失败")
}
return accountIDs, nil
}
func (s *Service) clearRolePermissionCaches(ctx context.Context, accountIDs []uint) {
if len(accountIDs) == 0 || s.redisClient == nil {
return
}
pipe := s.redisClient.Pipeline()
for _, accountID := range accountIDs {
pipe.Del(ctx, constants.RedisUserPermissionsKey(accountID))
}
_, _ = pipe.Exec(ctx)
}

View File

@@ -3,6 +3,7 @@ package shop
import (
"context"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
@@ -10,16 +11,28 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
db *gorm.DB
redisClient *redis.Client
accessAudit accessauditapp.Writer
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
shopRoleStore *postgres.ShopRoleStore
roleStore *postgres.RoleStore
}
// SetAccessAudit 注入店铺角色授权的事务、缓存和统一审计边界。
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
s.db = db
s.redisClient = redisClient
s.accessAudit = writer
}
func New(
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
@@ -290,36 +303,90 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
shop, err := s.shopStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound, "店铺不存在")
if s.db == nil || s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "店铺删除审计接缝未配置")
}
var shop *model.Shop
var parent *model.Shop
var accounts []*model.Account
var accountIDs []uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var locked model.Shop
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeShopNotFound, "店铺不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺失败")
}
return errors.Wrap(errors.CodeInternalError, err, "获取店铺失败")
}
accounts, err := s.accountStore.GetByShopID(ctx, shop.ID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询店铺账号失败")
}
if len(accounts) > 0 {
accountIDs := make([]uint, 0, len(accounts))
shop = &locked
parent = loadDeletedShopParent(tx, locked.ParentID)
if err := tx.Where("shop_id = ?", locked.ID).Find(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
}
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
accountIDs = make([]uint, 0, len(accounts))
for _, account := range accounts {
accountIDs = append(accountIDs, account.ID)
accountChanges = append(accountChanges, accessauditapp.AccountChange{
Account: account, BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": constants.StatusDisabled},
})
}
if err := s.accountStore.BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "禁用店铺账号失败")
if len(accountIDs) > 0 {
if err := postgres.NewAccountStore(tx, nil).BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "禁用店铺账号失败")
}
}
if err := tx.Delete(&model.Shop{}, id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺失败")
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺", OperatorID: currentUserID,
Shop: shop, ParentShop: parent, Accounts: accountChanges,
BeforeData: map[string]any{"deleted": false}, AfterData: map[string]any{"deleted": true},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺已删除",
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺删除审计失败")
}
return nil
})
if err != nil {
if shop != nil {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺失败", Result: shopRoleFailureResult(err),
OperatorID: currentUserID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, err)
}
return err
}
if err := s.shopStore.Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除店铺失败")
}
s.clearDeletedShopCaches(ctx, shop.ID, shop.ParentID, accountIDs)
return nil
}
func (s *Service) clearDeletedShopCaches(ctx context.Context, shopID uint, parentID *uint, accountIDs []uint) {
if s.redisClient == nil {
return
}
keys := []string{constants.RedisShopSubordinatesKey(shopID)}
if parentID != nil {
keys = append(keys, constants.RedisShopSubordinatesKey(*parentID))
}
for _, accountID := range accountIDs {
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
}
_ = s.redisClient.Del(ctx, keys...).Err()
}
func loadDeletedShopParent(tx *gorm.DB, parentID *uint) *model.Shop {
if parentID == nil {
return nil
}
var parent model.Shop
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
return nil
}
return &parent
}
// GetSubordinateShopIDs 获取下级店铺 ID 列表(包含自己)
func (s *Service) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
return s.shopStore.GetSubordinateShopIDs(ctx, shopID)

View File

@@ -2,12 +2,18 @@ package shop
import (
"context"
stderrors "errors"
"slices"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []uint) ([]*model.ShopRole, error) {
@@ -20,52 +26,11 @@ func (s *Service) AssignRolesToShop(ctx context.Context, shopID uint, roleIDs []
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
}
currentUserID := middleware.GetUserIDFromContext(ctx)
if len(roleIDs) == 0 {
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "清空店铺角色失败")
}
return []*model.ShopRole{}, nil
}
roles, err := s.roleStore.GetByIDs(ctx, roleIDs)
shopRoles, changedRoles, err := s.assignShopRoles(ctx, shop, middleware.GetUserIDFromContext(ctx), roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询角色失败")
s.recordShopRoleFailure(ctx, constants.AuditActionShopRolesAssigned, shop, changedRoles, err)
return nil, err
}
if len(roles) != len(roleIDs) {
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
}
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
}
if role.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
}
}
if err := s.shopRoleStore.DeleteByShopID(ctx, shopID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "删除现有店铺角色失败")
}
shopRoles := make([]*model.ShopRole, 0, len(roleIDs))
for _, roleID := range roleIDs {
shopRole := &model.ShopRole{
ShopID: shop.ID,
RoleID: roleID,
Status: constants.StatusEnabled,
Creator: currentUserID,
Updater: currentUserID,
}
shopRoles = append(shopRoles, shopRole)
}
if err := s.shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "批量创建店铺角色失败")
}
return shopRoles, nil
}
@@ -132,14 +97,234 @@ func (s *Service) DeleteShopRole(ctx context.Context, shopID, roleID uint) error
return err
}
_, err := s.shopStore.GetByID(ctx, shopID)
shop, err := s.shopStore.GetByID(ctx, shopID)
if err != nil {
return errors.New(errors.CodeNotFound, "店铺不存在")
}
if err := s.shopRoleStore.Delete(ctx, shopID, roleID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除店铺角色失败")
role, err := s.removeShopRole(ctx, shop, middleware.GetUserIDFromContext(ctx), roleID)
if err != nil {
roles := []*model.Role(nil)
if role != nil {
roles = []*model.Role{role}
}
s.recordShopRoleFailure(ctx, constants.AuditActionShopRoleDeleted, shop, roles, err)
return err
}
return nil
}
func (s *Service) assignShopRoles(ctx context.Context, shop *model.Shop, operatorID uint, requested []uint) ([]*model.ShopRole, []*model.Role, error) {
if s.db == nil || s.accessAudit == nil {
return nil, nil, errors.New(errors.CodeInvalidStatus, "店铺角色审计接缝未配置")
}
var shopRoles []*model.ShopRole
var changedRoles []*model.Role
var accountIDs []uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Shop{}, shop.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺角色关系失败")
}
shopRoleStore := postgres.NewShopRoleStore(tx, nil)
roleStore := postgres.NewRoleStore(tx)
beforeIDs, err := shopRoleStore.GetRoleIDsByShopID(ctx, shop.ID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺现有角色失败")
}
requestedRoles, err := validateShopRoles(ctx, roleStore, requested)
if err != nil {
return err
}
changedRoles, err = loadChangedRoles(ctx, roleStore, beforeIDs, requested, requestedRoles)
if err != nil {
return err
}
if err := shopRoleStore.DeleteByShopID(ctx, shop.ID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除现有店铺角色失败")
}
shopRoles = make([]*model.ShopRole, 0, len(requested))
for _, roleID := range requested {
shopRoles = append(shopRoles, &model.ShopRole{ShopID: shop.ID, RoleID: roleID, Status: constants.StatusEnabled, Creator: operatorID, Updater: operatorID})
}
if err := shopRoleStore.BatchCreate(ctx, shopRoles); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "批量创建店铺角色失败")
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopRolesAssigned, Summary: "分配店铺默认角色",
OperatorID: operatorID, Shop: shop, Roles: shopRoleChanges(changedRoles, beforeIDs, requested),
BeforeData: map[string]any{"role_ids": sortedShopRoleIDs(beforeIDs)},
AfterData: map[string]any{"role_ids": sortedShopRoleIDs(requested)},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺角色审计失败")
}
accountIDs, err = shopPermissionCacheAccountIDs(ctx, tx, shop.ID)
return err
})
if err != nil {
return nil, changedRoles, err
}
s.clearShopPermissionCaches(ctx, accountIDs)
return shopRoles, changedRoles, nil
}
func (s *Service) removeShopRole(ctx context.Context, shop *model.Shop, operatorID, roleID uint) (*model.Role, error) {
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺角色审计接缝未配置")
}
var removed *model.Role
var accountIDs []uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&model.Shop{}, shop.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺角色关系失败")
}
shopRoles := postgres.NewShopRoleStore(tx, nil)
beforeIDs, err := shopRoles.GetRoleIDsByShopID(ctx, shop.ID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺现有角色失败")
}
if !slices.Contains(beforeIDs, roleID) {
return nil
}
removed, err = postgres.NewRoleStore(tx).GetByID(ctx, roleID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待移除店铺角色失败")
}
if err := shopRoles.Delete(ctx, shop.ID, roleID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺角色失败")
}
afterIDs := removeShopRoleID(beforeIDs, roleID)
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopRoleDeleted, Summary: "删除店铺默认角色",
OperatorID: operatorID, Shop: shop,
Roles: []accessauditapp.RoleChange{{Role: removed, BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false}}},
BeforeData: map[string]any{"role_ids": sortedShopRoleIDs(beforeIDs)},
AfterData: map[string]any{"role_ids": sortedShopRoleIDs(afterIDs)},
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺角色审计失败")
}
accountIDs, err = shopPermissionCacheAccountIDs(ctx, tx, shop.ID)
return err
})
if err == nil {
s.clearShopPermissionCaches(ctx, accountIDs)
}
return removed, err
}
func validateShopRoles(ctx context.Context, store *postgres.RoleStore, roleIDs []uint) ([]*model.Role, error) {
if len(roleIDs) == 0 {
return nil, nil
}
roles, err := store.GetByIDs(ctx, roleIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询角色失败")
}
if len(roles) != len(roleIDs) {
return nil, errors.New(errors.CodeNotFound, "部分角色不存在")
}
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return nil, errors.New(errors.CodeInvalidParam, "店铺只能分配客户角色")
}
if role.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeInvalidParam, "角色已禁用")
}
}
return roles, nil
}
func loadChangedRoles(ctx context.Context, store *postgres.RoleStore, beforeIDs, afterIDs []uint, afterRoles []*model.Role) ([]*model.Role, error) {
changedIDs := make([]uint, 0, len(beforeIDs)+len(afterIDs))
before, after := shopRoleIDSet(beforeIDs), shopRoleIDSet(afterIDs)
for _, id := range beforeIDs {
if !after[id] {
changedIDs = append(changedIDs, id)
}
}
for _, role := range afterRoles {
if !before[role.ID] {
changedIDs = append(changedIDs, role.ID)
}
}
roles, err := store.GetByIDs(ctx, changedIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询变更角色失败")
}
return roles, nil
}
func shopPermissionCacheAccountIDs(ctx context.Context, tx *gorm.DB, shopID uint) ([]uint, error) {
var accountIDs []uint
if err := tx.WithContext(ctx).Model(&model.Account{}).Where("shop_id = ?", shopID).Pluck("id", &accountIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
}
return accountIDs, nil
}
func (s *Service) clearShopPermissionCaches(ctx context.Context, accountIDs []uint) {
if len(accountIDs) == 0 || s.redisClient == nil {
return
}
keys := make([]string, 0, len(accountIDs))
for _, accountID := range accountIDs {
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
}
_ = s.redisClient.Del(ctx, keys...).Err()
}
func (s *Service) recordShopRoleFailure(ctx context.Context, action string, shop *model.Shop, roles []*model.Role, originalErr error) {
changes := make([]accessauditapp.RoleChange, 0, len(roles))
for _, role := range roles {
changes = append(changes, accessauditapp.RoleChange{Role: role})
}
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: action, Summary: "店铺角色操作失败", Result: shopRoleFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, Roles: changes,
}, originalErr)
}
func shopRoleChanges(roles []*model.Role, beforeIDs, afterIDs []uint) []accessauditapp.RoleChange {
before, after := shopRoleIDSet(beforeIDs), shopRoleIDSet(afterIDs)
changes := make([]accessauditapp.RoleChange, 0, len(roles))
for _, role := range roles {
changes = append(changes, accessauditapp.RoleChange{
Role: role, BeforeData: map[string]any{"assigned": before[role.ID]}, AfterData: map[string]any{"assigned": after[role.ID]},
})
}
return changes
}
func shopRoleFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeRoleNotFound:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
func shopRoleIDSet(ids []uint) map[uint]bool {
set := make(map[uint]bool, len(ids))
for _, id := range ids {
set[id] = true
}
return set
}
func sortedShopRoleIDs(ids []uint) []uint {
result := append([]uint(nil), ids...)
slices.Sort(result)
return result
}
func removeShopRoleID(ids []uint, removed uint) []uint {
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id != removed {
result = append(result, id)
}
}
return result
}