feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
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:
252
internal/application/shop/batch_business_owner.go
Normal file
252
internal/application/shop/batch_business_owner.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user