固化七月迭代审计治理进展以隔离线上热修
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:
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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 = ¤t
|
||||
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
|
||||
|
||||
117
internal/service/device/batch_audit.go
Normal file
117
internal/service/device/batch_audit.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = ¤tUserID
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
// 验证企业存在
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = ¤t
|
||||
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) {
|
||||
// 获取客户信息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user