feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s

- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。
- 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。
- 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。
- 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。
- 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。
- 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
This commit is contained in:
2026-09-14 16:51:44 +08:00
parent 957a235585
commit c7f9e005af
56 changed files with 4166 additions and 344 deletions

View File

@@ -0,0 +1,252 @@
package shop
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// BusinessOwnerBatchChange 描述一家店铺在批量交接中的负责人前后事实。
// 账号快照用于审计引用资源,已软删账号同样保留历史事实。
type BusinessOwnerBatchChange struct {
Shop *model.Shop
BeforeOwnerID *uint
AfterOwnerID *uint
PreviousOwner *model.Account
Owner *model.Account
}
// BusinessOwnerBatchAudit 描述一次批量交接的批次根事实与逐店子事实。
// Result 为空表示成功批次,由实现写批次根事件与逐店子事件;
// 非空表示业务回滚后的失败或拒绝事实,此时只写批次根事件。
type BusinessOwnerBatchAudit struct {
BatchKey string
Operation string
Result string
OperatorID uint
Total int
Owner *model.Account
Changes []BusinessOwnerBatchChange
}
// BusinessOwnerBatchAuditWriter 接收店铺负责人批量交接受理事务内的审计事实。
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
type BusinessOwnerBatchAuditWriter interface {
WriteBusinessOwnerBatch(ctx context.Context, tx *gorm.DB, batch BusinessOwnerBatchAudit) error
}
// SetBatchBusinessOwnerAudit 注入批量交接的批次审计接缝。
func (s *BatchBusinessOwnerService) SetBatchBusinessOwnerAudit(writer BusinessOwnerBatchAuditWriter) {
s.batchAudit = writer
}
// BatchBusinessOwnerService 收口勾选店铺批量设置或清空平台业务员负责人的事务脚本。
// 全量预校验通过后在同一事务内统一更新并逐店写审计;任一项失败整批不修改,
// 且失败文案不区分无权、不存在与已删除。
type BatchBusinessOwnerService struct {
db *gorm.DB
batchAudit BusinessOwnerBatchAuditWriter
}
// NewBatchBusinessOwnerService 创建店铺负责人批量交接事务脚本。
func NewBatchBusinessOwnerService(db *gorm.DB) *BatchBusinessOwnerService {
return &BatchBusinessOwnerService{db: db}
}
// Execute 批量设置或清空店铺负责人。
func (s *BatchBusinessOwnerService) Execute(ctx context.Context, request *dto.BatchUpdateShopBusinessOwnerRequest) (*dto.BatchUpdateShopBusinessOwnerResult, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if !request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeInvalidParam, "必须显式提交业务员归属字段null 表示清空")
}
shopIDs, err := normalizeShopIDs(request.ShopIDs)
if err != nil {
return nil, err
}
if s.batchAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺负责人批量交接统一审计接缝未配置")
}
operation := "clear"
if request.BusinessOwnerAccountID != nil {
operation = "assign"
}
batchKey := batchEventPrefix + uuid.NewString()
var result *dto.BatchUpdateShopBusinessOwnerResult
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedShops, err := lockManageableShops(ctx, tx, shopIDs)
if err != nil {
return err
}
// 命中数不等于请求数即失败,不区分越权、不存在与已删除,避免泄露店铺存在性。
if len(lockedShops) != len(shopIDs) {
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
var owner *uint
var ownerAccount *model.Account
if request.BusinessOwnerAccountID != nil {
account, err := validateBatchBusinessOwner(ctx, tx, *request.BusinessOwnerAccountID)
if err != nil {
return err
}
ownerID := account.ID
owner, ownerAccount = &ownerID, account
}
update := tx.WithContext(ctx).Model(&model.Shop{}).Where("id IN ?", shopIDs).
Updates(map[string]any{
"business_owner_account_id": owner, "updater": operatorID, "updated_at": time.Now(),
})
if update.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, update.Error, "批量更新店铺负责人失败")
}
if int(update.RowsAffected) != len(shopIDs) {
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
if err := s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
BatchKey: batchKey, Operation: operation, OperatorID: operatorID,
Total: len(shopIDs), Owner: ownerAccount,
Changes: collectBatchChanges(ctx, tx, lockedShops, owner, ownerAccount),
}); err != nil {
return err
}
result = &dto.BatchUpdateShopBusinessOwnerResult{
BatchKey: batchKey, ShopCount: len(shopIDs), Cleared: owner == nil, BusinessOwnerAccountID: owner,
}
return nil
})
if txErr != nil {
s.recordFailure(ctx, batchKey, operation, operatorID, shopIDs, txErr)
return nil, txErr
}
return result, nil
}
// collectBatchChanges 装配逐店审计事实:锁定的店铺携带变更前负责人,
// 原负责人账号按一次批量查询载入,目标账号快照由调用方复用,避免 N+1。
func collectBatchChanges(ctx context.Context, tx *gorm.DB, shops []*model.Shop, owner *uint, ownerAccount *model.Account) []BusinessOwnerBatchChange {
previousIDs := make([]uint, 0, len(shops))
seen := make(map[uint]struct{}, len(shops))
for _, shop := range shops {
if shop.BusinessOwnerAccountID == nil {
continue
}
id := *shop.BusinessOwnerAccountID
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
previousIDs = append(previousIDs, id)
}
previous := make(map[uint]*model.Account, len(previousIDs))
if len(previousIDs) > 0 {
var accounts []*model.Account
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", previousIDs).Find(&accounts).Error; err == nil {
for _, account := range accounts {
previous[account.ID] = account
}
}
}
changes := make([]BusinessOwnerBatchChange, 0, len(shops))
for _, shop := range shops {
change := BusinessOwnerBatchChange{Shop: shop, BeforeOwnerID: shop.BusinessOwnerAccountID, AfterOwnerID: owner, Owner: ownerAccount}
if shop.BusinessOwnerAccountID != nil {
change.PreviousOwner = previous[*shop.BusinessOwnerAccountID]
}
changes = append(changes, change)
}
return changes
}
// recordFailure 在业务回滚后使用独立短事务记录批次失败或拒绝事实。
// 二次写入失败不能静默丢弃,按 pkg/auditfailure 既有先例上报为关键级失败。
func (s *BatchBusinessOwnerService) recordFailure(ctx context.Context, batchKey, operation string, operatorID uint, shopIDs []uint, originalErr error) {
writeErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
BatchKey: batchKey, Operation: operation, Result: shopAuditFailureResult(originalErr),
OperatorID: operatorID, Total: len(shopIDs),
})
})
if writeErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionShopBusinessOwnerBatchUpdated,
batchKey, "", batchKey, strconv.Itoa(errorCodeOf(originalErr)), writeErr)
}
}
// errorCodeOf 返回稳定错误的编码文本,非稳定错误归入内部错误码。
func errorCodeOf(err error) int {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr.Code
}
return errors.CodeInternalError
}
// batchBusinessOwnerFailureMessage 复用平台维护入口的统一失败文案,不区分无权、不存在与已删除。
const batchBusinessOwnerFailureMessage = constants.PlatformManagementForbiddenMessage
// batchEventPrefix 是批次根事件标识前缀,与随机后缀共同保证稳定且不超审计列宽。
const batchEventPrefix = "shop-owner-batch:"
// normalizeShopIDs 去重并保持首次出现顺序,空集合视为非法参数。
func normalizeShopIDs(values []uint) ([]uint, error) {
if len(values) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "店铺ID列表不能为空")
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
return nil, errors.New(errors.CodeInvalidParam, "店铺ID非法")
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result, nil
}
// lockManageableShops 在数据范围约束下按主键加行锁读取全部目标店铺。
func lockManageableShops(ctx context.Context, tx *gorm.DB, shopIDs []uint) ([]*model.Shop, error) {
query := middleware.ApplyShopIDFilter(ctx, tx.WithContext(ctx).Model(&model.Shop{}))
var shops []*model.Shop
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定批量交接目标店铺失败")
}
return shops, nil
}
// validateBatchBusinessOwner 校验目标账号是当前启用的平台业务员。
func validateBatchBusinessOwner(ctx context.Context, tx *gorm.DB, accountID uint) (*model.Account, error) {
if accountID == 0 {
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
var account model.Account
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "SHARE"}).
Where("id = ? AND user_type = ? AND status = ?", accountID, constants.UserTypePlatform, constants.StatusEnabled).
First(&account).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
}
return &account, nil
}