Files
junhong_cmp_fiber/internal/application/businessusergroup/service.go
break c7f9e005af
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
- 迁移 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 验证证据链。
2026-09-14 16:51:44 +08:00

494 lines
19 KiB
Go

// 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
}