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

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

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

Confidence: medium

Scope-risk: broad

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

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

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

View File

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

View File

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