// Package businessusergroup 收口业务用户组、成员归属与店铺负责人批量交接的写用例。 // 组只描述平台用户的业务分类,不改变后台角色、登录、权限或数据范围; // 店铺所属组始终由当前负责人实时推导,因此本包不写任何店铺组字段。 package businessusergroup import ( "context" "strconv" "strings" "time" "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/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" ) // Service 业务用户组与成员归属的简单写事务脚本。 type Service struct { db *gorm.DB groupStore *postgres.BusinessUserGroupStore auditWriter *audit.Writer } // New 创建业务用户组事务脚本。 func New(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore, auditWriters ...*audit.Writer) *Service { service := &Service{db: db, groupStore: groupStore} if len(auditWriters) > 0 { service.auditWriter = auditWriters[0] } return service } // Create 创建业务用户组并在同一事务写入审计。 func (s *Service) Create(ctx context.Context, request *dto.CreateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) { operatorID, err := s.requireOperator(ctx) if err != nil { return nil, err } code := strings.TrimSpace(request.Code) name := strings.TrimSpace(request.Name) if code == "" || name == "" { return nil, errors.New(errors.CodeInvalidParam, "业务用户组编码与名称不能为空") } if !constants.IsValidBusinessLine(request.BusinessLine) { return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法") } group := &model.BusinessUserGroup{ Code: code, Name: name, BusinessLine: request.BusinessLine, SortOrder: sortValue(request.Sort), Status: statusValue(request.Enabled), Remark: request.Remark, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID}, } var response *dto.BusinessUserGroupResponse if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { store := s.groupStore.WithTx(tx) exists, err := store.ExistsCode(ctx, code, 0) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "校验业务用户组编码失败") } if exists { return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在") } if err := store.Create(ctx, group); err != nil { return mapCodeConflict(err) } if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupCreated, "创建业务用户组", group, operatorID, nil, groupSnapshot(group)); err != nil { return err } response = toGroupResponse(group) return nil }); err != nil { return nil, err } return response, nil } // Update 更新业务用户组名称、业务线、排序、启停与备注;稳定编码永不允许修改。 func (s *Service) Update(ctx context.Context, groupID uint, request *dto.UpdateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) { operatorID, err := s.requireOperator(ctx) if err != nil { return nil, err } if groupID == 0 { return nil, errors.New(errors.CodeInvalidParam) } if request.BusinessLine != nil && !constants.IsValidBusinessLine(*request.BusinessLine) { return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法") } if request.Name != nil && strings.TrimSpace(*request.Name) == "" { return nil, errors.New(errors.CodeInvalidParam, "业务用户组名称不能为空") } var response *dto.BusinessUserGroupResponse err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { store := s.groupStore.WithTx(tx) group, err := store.LockByID(ctx, groupID) if err != nil { return groupLookupError(err) } before := groupSnapshot(group) if request.Name != nil { group.Name = strings.TrimSpace(*request.Name) } if request.BusinessLine != nil { group.BusinessLine = *request.BusinessLine } if request.Sort != nil { group.SortOrder = *request.Sort } // 停用保留成员关系:已有成员继续显示已停用,只是不得新增成员或作为批量目标。 if request.Enabled != nil { group.Status = statusValue(request.Enabled) } if request.Remark != nil { group.Remark = *request.Remark } if err := store.Update(ctx, group, operatorID); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新业务用户组失败") } after := groupSnapshot(group) // 启停是独立状态事实,与资料变更分开记录,保证审计动作可被独立检索。 if before["status"] != after["status"] { action, summary := constants.AuditActionBusinessUserGroupEnabled, "启用业务用户组" if group.Status != constants.StatusEnabled { action, summary = constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组" } if err := s.appendGroupAudit(ctx, tx, action, summary, group, operatorID, map[string]any{"status": before["status"]}, map[string]any{"status": after["status"]}); err != nil { return err } } if groupProfileChanged(before, after) { if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupUpdated, "更新业务用户组", group, operatorID, before, after); err != nil { return err } } response = toGroupResponse(group) return nil }) if err != nil { return nil, err } return response, nil } // Delete 删除无成员的业务用户组;有成员时只能停用或先移走成员。 func (s *Service) Delete(ctx context.Context, groupID uint) error { operatorID, err := s.requireOperator(ctx) if err != nil { return err } if groupID == 0 { return errors.New(errors.CodeInvalidParam) } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { store := s.groupStore.WithTx(tx) group, err := store.LockByID(ctx, groupID) if err != nil { return groupLookupError(err) } count, err := store.CountMembers(ctx, group.ID) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败") } if count > 0 { return errors.New(errors.CodeInvalidStatus, "用户组仍有成员,只能停用或先移走成员") } before := groupSnapshot(group) if err := store.Delete(ctx, group.ID, operatorID); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "删除业务用户组失败") } return s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", group, operatorID, before, nil) }) } // SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属。 // 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。 func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) { operatorID, err := s.requireOperator(ctx) if err != nil { return nil, err } accountIDs, err := normalizeAccountIDs(request.AccountIDs) if err != nil { return nil, err } if groupID == 0 { return nil, errors.New(errors.CodeInvalidParam) } result := &dto.BusinessUserGroupMembersResult{GroupID: groupID, AccountIDs: accountIDs} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { store := s.groupStore.WithTx(tx) group, err := store.LockByID(ctx, groupID) if err != nil { return groupLookupError(err) } if group.Status != constants.StatusEnabled { return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标") } if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil { return err } // 按 id 升序锁账号行:账号行锁保证同一账号串行化, // 同时消除「清空时无成员行导致锁不到行」的幻读与「多账号相反顺序」的死锁。 if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败") } before, err := store.MembersByAccountIDs(ctx, accountIDs) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败") } if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil { return mapMemberWriteError(err) } return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID) }) if err != nil { return nil, err } return result, nil } // ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改。 func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) { operatorID, err := s.requireOperator(ctx) if err != nil { return nil, err } accountIDs, err := normalizeAccountIDs(request.AccountIDs) if err != nil { return nil, err } result := &dto.BusinessUserGroupMembersResult{AccountIDs: accountIDs} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { store := s.groupStore.WithTx(tx) if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil { return err } if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败") } before, err := store.MembersByAccountIDs(ctx, accountIDs) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败") } if err := store.ClearMembers(ctx, accountIDs); err != nil { return mapMemberWriteError(err) } return s.appendMemberAudits(ctx, tx, nil, accountIDs, before, operatorID) }) if err != nil { return nil, err } return result, nil } // mapMemberWriteError 把成员关系写入失败收敛为稳定业务错误。 // 并发为同一账号新增成员关系时唯一索引是最终裁决,不能把约束冲突暴露成 500。 func mapMemberWriteError(err error) error { if err == nil { return nil } if postgres.IsAccountMemberConflict(err) { return errors.New(errors.CodeConflict, "平台用户分组归属已被并发修改,请重试") } return errors.Wrap(errors.CodeDatabaseError, err, "更新平台用户分组失败") } // requireOperator 校验调用者具备平台维护入口身份,并返回其账号 ID。 func (s *Service) requireOperator(ctx context.Context) (uint, error) { userType := middleware.GetUserTypeFromContext(ctx) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } operatorID := middleware.GetUserIDFromContext(ctx) if operatorID == 0 { return 0, errors.New(errors.CodeUnauthorized) } return operatorID, nil } // normalizeAccountIDs 去重并保持首次出现顺序,空集合视为非法参数。 func normalizeAccountIDs(values []uint) ([]uint, error) { if len(values) == 0 { return nil, errors.New(errors.CodeInvalidParam, "账号列表不能为空") } 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 } // ensureEnabledPlatformAccounts 校验全部账号都是当前启用的平台用户,任一不满足即整批失败。 // 账号有效性统一走共享谓词,避免各入口对「平台 + 启用 + 未软删」出现口径分叉。 func ensureEnabledPlatformAccounts(ctx context.Context, tx *gorm.DB, accountIDs []uint) error { var accounts []model.Account if err := tx.WithContext(ctx).Model(&model.Account{}). Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "校验平台用户失败") } valid := 0 for _, account := range accounts { if constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) { valid++ } } if valid != len(accountIDs) { return errors.New(errors.CodeInvalidParam, "存在无效或非启用的平台用户账号,整批未修改") } return nil } func groupLookupError(err error) error { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "业务用户组不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败") } // mapCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误,并发创建以唯一索引为最终裁决。 func mapCodeConflict(err error) error { if err == nil { return nil } if strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_code") { return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在") } return errors.Wrap(errors.CodeDatabaseError, err, "创建业务用户组失败") } func sortValue(value *int64) int64 { if value == nil { return 0 } return *value } func statusValue(enabled *bool) int { if enabled == nil || *enabled { return constants.StatusEnabled } return constants.StatusDisabled } func toGroupResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse { return &dto.BusinessUserGroupResponse{ ID: group.ID, Code: group.Code, Name: group.Name, BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine), Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark, CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339), } } // groupSnapshot 生成业务用户组的前后值快照,不含任何凭证或敏感信息。 func groupSnapshot(group *model.BusinessUserGroup) map[string]any { if group == nil { return nil } return map[string]any{ "id": group.ID, "code": group.Code, "name": group.Name, "business_line": group.BusinessLine, "sort_order": group.SortOrder, "status": group.Status, "remark": group.Remark, } } // groupProfileChanged 判断除启停外的可维护字段是否发生变化;编码不可修改,不参与比较。 func groupProfileChanged(before, after map[string]any) bool { for _, field := range []string{"name", "business_line", "sort_order", "remark"} { if before[field] != after[field] { return true } } return false } // businessUserGroupKey 返回业务用户组审计资源的稳定 Key。 func businessUserGroupKey(group *model.BusinessUserGroup) string { if group == nil { return "" } if group.Code != "" { return group.Code } return strconv.FormatUint(uint64(group.ID), 10) } // businessUserGroupIdentity 返回业务用户组审计身份快照,字段必须落在注册表白名单内。 func businessUserGroupIdentity(group *model.BusinessUserGroup) map[string]any { if group == nil { return nil } return map[string]any{ "id": group.ID, "code": group.Code, "name": group.Name, "business_line": group.BusinessLine, "status": group.Status, } } // appendGroupAudit 在业务事务内追加业务用户组事件。 func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, summary string, group *model.BusinessUserGroup, operatorID uint, before, after map[string]any) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置") } var resourceID *string if group.ID != 0 { value := strconv.FormatUint(uint64(group.ID), 10) resourceID = &value } s.auditWriter.Append(ctx, tx, audit.AppendInput{ ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess, Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Resources: []audit.ResourceInput{{ Type: constants.AuditResourceBusinessUserGroup, ID: resourceID, Key: businessUserGroupKey(group), DisplayName: group.Name, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBusinessUserGroupTarget, IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after, }}, }) return nil } // appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。 // 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组。 func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置") } var accounts []model.Account if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询成员审计账号失败") } accountByID := make(map[uint]model.Account, len(accounts)) for _, account := range accounts { accountByID[account.ID] = account } summary := "清空平台用户业务用户组归属" afterGroupID := any(nil) if group != nil { summary = "设置平台用户业务用户组归属" afterGroupID = group.ID } for _, accountID := range accountIDs { account, exists := accountByID[accountID] if !exists { continue } beforeGroupID := any(nil) if member, ok := before[accountID]; ok { beforeGroupID = member.BusinessUserGroupID } resource := audit.AccountResource(&account, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleAccountTarget) resource.BeforeData = map[string]any{"business_user_group_id": beforeGroupID} resource.AfterData = map[string]any{"business_user_group_id": afterGroupID} resources := []audit.ResourceInput{resource} if group != nil { resources = append(resources, audit.ResourceInput{ Type: constants.AuditResourceBusinessUserGroup, ID: optionalID(group.ID), Key: businessUserGroupKey(group), DisplayName: group.Name, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleBusinessUserGroupTarget, IdentitySnapshot: businessUserGroupIdentity(group), SortOrder: 1, }) } s.auditWriter.Append(ctx, tx, audit.AppendInput{ ActionCode: constants.AuditActionBusinessUserGroupMembersUpdated, Summary: summary, Result: constants.AuditResultSuccess, Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Resources: resources, }) } return nil } func optionalID(id uint) *string { if id == 0 { return nil } value := strconv.FormatUint(uint64(id), 10) return &value }