实施业务用户组成员管理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m42s

This commit is contained in:
2026-09-21 21:20:38 +08:00
parent 4a16eb0b1e
commit 22c3e7cc1a
14 changed files with 836 additions and 106 deletions

View File

@@ -177,9 +177,8 @@ func (s *Service) Delete(ctx context.Context, groupID uint) error {
})
}
// SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属
// 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。
func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
// AddMembers 增量增加多个启用且未删除的平台用户到指定启用组
func (s *Service) AddMembers(ctx context.Context, groupID uint, request *dto.AddBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, error) {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return nil, err
@@ -191,7 +190,7 @@ func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.Set
if groupID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
result := &dto.BusinessUserGroupMembersResult{GroupID: groupID, AccountIDs: accountIDs}
result := &dto.BusinessUserGroupMemberMutationResult{GroupID: groupID, AccountIDs: accountIDs, RequestedCount: len(accountIDs)}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.groupStore.WithTx(tx)
group, err := store.LockByID(ctx, groupID)
@@ -201,22 +200,37 @@ func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.Set
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, "锁定平台用户账号失败")
}
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
return err
}
before, err := store.MembersByAccountIDs(ctx, accountIDs)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
}
changed := make([]uint, 0, len(accountIDs))
for _, accountID := range accountIDs {
member, exists := before[accountID]
switch {
case !exists:
result.AddedCount++
result.AddedAccountIDs = append(result.AddedAccountIDs, accountID)
changed = append(changed, accountID)
case member.BusinessUserGroupID != groupID:
result.MovedCount++
result.MovedAccountIDs = append(result.MovedAccountIDs, accountID)
changed = append(changed, accountID)
default:
result.UnchangedCount++
result.UnchangedAccountIDs = append(result.UnchangedAccountIDs, accountID)
}
}
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
return mapMemberWriteError(err)
}
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID)
return s.appendMemberAudits(ctx, tx, group, changed, before, operatorID, false)
})
if err != nil {
return nil, err
@@ -224,8 +238,8 @@ func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.Set
return result, nil
}
// ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改
func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
// RemoveMembers 按目标组作用域增量移除成员;目标组启用或停用均可操作
func (s *Service) RemoveMembers(ctx context.Context, groupID uint, request *dto.RemoveBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, error) {
operatorID, err := s.requireOperator(ctx)
if err != nil {
return nil, err
@@ -234,11 +248,15 @@ func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUs
if err != nil {
return nil, err
}
result := &dto.BusinessUserGroupMembersResult{AccountIDs: accountIDs}
if groupID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
result := &dto.BusinessUserGroupMemberMutationResult{GroupID: groupID, AccountIDs: accountIDs, RequestedCount: len(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
group, err := store.LockByID(ctx, groupID)
if err != nil {
return groupLookupError(err)
}
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
@@ -247,10 +265,22 @@ func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUs
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
}
if err := store.ClearMembers(ctx, accountIDs); err != nil {
for _, accountID := range accountIDs {
member, exists := before[accountID]
if !exists || member.BusinessUserGroupID != group.ID {
return errors.New(errors.CodeConflict, "存在账号已不属于目标用户组,整批未修改")
}
}
affected, err := store.RemoveMembersByGroup(ctx, group.ID, accountIDs, operatorID)
if err != nil {
return mapMemberWriteError(err)
}
return s.appendMemberAudits(ctx, tx, nil, accountIDs, before, operatorID)
if affected != int64(len(accountIDs)) {
return errors.New(errors.CodeConflict, "目标用户组成员已被并发修改,整批未修改")
}
result.RemovedCount = len(accountIDs)
result.RemovedAccountIDs = append(result.RemovedAccountIDs, accountIDs...)
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID, true)
})
if err != nil {
return nil, err
@@ -418,7 +448,7 @@ func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, sum
value := strconv.FormatUint(uint64(group.ID), 10)
resourceID = &value
}
s.auditWriter.Append(ctx, tx, audit.AppendInput{
_, err := s.auditWriter.AppendAndGet(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,
@@ -429,12 +459,12 @@ func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, sum
IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after,
}},
})
return nil
return err
}
// appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。
// 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) error {
// appendMemberAudits 在业务事务内为每个实际变化账号追加一条成员归属事件。
// 账号是实际被替换归属的资源;目标组作为引用资源,移除时 after 为空
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint, removing bool) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
}
@@ -446,16 +476,21 @@ func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *mo
for _, account := range accounts {
accountByID[account.ID] = account
}
summary := "清空平台用户业务用户组归属"
summary := "增量移除平台用户业务用户组成员"
afterGroupID := any(nil)
if group != nil {
summary = "设置平台用户业务用户组归属"
afterGroupID = group.ID
if !removing {
summary = "增量维护平台用户业务用户组成员"
if group != nil {
afterGroupID = group.ID
}
}
if removing && group == nil {
return errors.New(errors.CodeInvalidStatus, "移除成员审计缺少原用户组")
}
for _, accountID := range accountIDs {
account, exists := accountByID[accountID]
if !exists {
continue
return errors.New(errors.CodeDatabaseError, "成员审计账号不存在")
}
beforeGroupID := any(nil)
if member, ok := before[accountID]; ok {
@@ -473,13 +508,15 @@ func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *mo
IdentitySnapshot: businessUserGroupIdentity(group), SortOrder: 1,
})
}
s.auditWriter.Append(ctx, tx, audit.AppendInput{
if _, err := s.auditWriter.AppendAndGet(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,
})
}); err != nil {
return err
}
}
return nil
}

View File

@@ -147,9 +147,65 @@ func (h *BusinessUserGroupHandler) Delete(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// SetMembers 批量设置平台用户的业务用户组归属,直接替换原归属
// PUT /api/admin/business-user-groups/:id/members
func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
// ListMembers 查询指定业务用户组保留的成员关系
// GET /api/admin/business-user-groups/:id/members
func (h *BusinessUserGroupHandler) ListMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BusinessUserGroupMemberListParams
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := pathID(c)
if err != nil {
return err
}
request.ID = id
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.query == nil {
return errors.New(errors.CodeInternalError, "业务用户组成员查询尚未配置")
}
result, err := h.query.ListMembers(c.UserContext(), request.ID, request.BusinessUserGroupMemberListRequest)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// ListMemberCandidates 查询指定业务用户组的成员候选。
// GET /api/admin/business-user-groups/:id/member-candidates
func (h *BusinessUserGroupHandler) ListMemberCandidates(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.BusinessUserGroupMemberCandidateParams
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := pathID(c)
if err != nil {
return err
}
request.ID = id
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.query == nil {
return errors.New(errors.CodeInternalError, "业务用户组候选查询尚未配置")
}
result, err := h.query.ListMemberCandidates(c.UserContext(), request.ID, request.BusinessUserGroupMemberCandidateRequest)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// AddMembers 增量增加平台用户到指定业务用户组。
// POST /api/admin/business-user-groups/:id/members
func (h *BusinessUserGroupHandler) AddMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
@@ -157,34 +213,38 @@ func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
if err != nil {
return err
}
var request dto.SetBusinessUserGroupMembersRequest
var request dto.AddBusinessUserGroupMembersRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.SetMembers(c.UserContext(), id, &request)
result, err := h.service.AddMembers(c.UserContext(), id, &request)
if err != nil {
return err
}
return response.Success(c, result)
}
// ClearMembers 批量清空平台用户的业务用户组归属
// DELETE /api/admin/business-user-groups/members
func (h *BusinessUserGroupHandler) ClearMembers(c *fiber.Ctx) error {
// RemoveMembers 从指定业务用户组增量移除成员
// DELETE /api/admin/business-user-groups/:id/members
func (h *BusinessUserGroupHandler) RemoveMembers(c *fiber.Ctx) error {
if err := requirePlatformManagement(c); err != nil {
return err
}
var request dto.ClearBusinessUserGroupMembersRequest
id, err := pathID(c)
if err != nil {
return err
}
var request dto.RemoveBusinessUserGroupMembersRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.ClearMembers(c.UserContext(), &request)
result, err := h.service.RemoveMembers(c.UserContext(), id, &request)
if err != nil {
return err
}

View File

@@ -46,6 +46,7 @@ type BusinessUserGroupResponse struct {
Sort int64 `json:"sort" description:"排序值"`
Enabled bool `json:"enabled" description:"是否启用;停用后不得新增成员,也不得作为批量目标"`
Remark string `json:"remark" description:"备注"`
MemberCount int64 `json:"member_count" description:"当前保留成员关系数量,包含禁用或已删除账号"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
@@ -58,20 +59,92 @@ type BusinessUserGroupPageResult struct {
Size int `json:"size" description:"每页数量"`
}
// SetBusinessUserGroupMembersRequest 批量设置平台用户业务用户组归属请求。
// 设置直接替换每个账号的原归属;任一账号不是启用平台用户或目标组未启用时整批不修改。
type SetBusinessUserGroupMembersRequest struct {
AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表至少一个每个账号必须是启用平台用户"`
// BusinessUserGroupMemberListRequest 业务用户组成员分页查询请求。
type BusinessUserGroupMemberListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按用户名或手机号模糊搜索,最多 100 字符"`
AccountStatus string `json:"account_status" query:"account_status" validate:"omitempty,oneof=enabled disabled deleted" enum:"enabled,disabled,deleted" description:"账号状态筛选 (enabled:启用, disabled:禁用, deleted:已删除)"`
}
// ClearBusinessUserGroupMembersRequest 批量清空平台用户业务用户组归属请求
type ClearBusinessUserGroupMembersRequest struct {
AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表至少一个每个账号必须是启用平台用户"`
// BusinessUserGroupMemberListParams 业务用户组成员分页查询路径与查询参数
type BusinessUserGroupMemberListParams struct {
BusinessUserGroupMemberRequest
BusinessUserGroupMemberListRequest
}
// BusinessUserGroupMemberResponse 业务用户组成员响应。
type BusinessUserGroupMemberResponse struct {
AccountID uint `json:"account_id" description:"平台用户账号ID"`
Username string `json:"username" description:"平台用户用户名"`
Phone string `json:"phone" description:"脱敏手机号仅保留前3位和后4位"`
Enabled bool `json:"enabled" description:"账号是否启用"`
StatusName string `json:"status_name" description:"账号启停状态名称"`
Deleted bool `json:"deleted" description:"账号是否已软删除"`
MemberCreatedAt string `json:"member_created_at" description:"成员关系创建时间"`
CurrentGroupID uint `json:"current_group_id" description:"当前业务用户组ID"`
CurrentGroupCode string `json:"current_group_code" description:"当前业务用户组稳定编码"`
CurrentGroupName string `json:"current_group_name" description:"当前业务用户组名称"`
CurrentGroupEnabled bool `json:"current_group_enabled" description:"当前业务用户组是否启用"`
}
// BusinessUserGroupMemberPageResult 业务用户组成员分页响应。
type BusinessUserGroupMemberPageResult struct {
Items []*BusinessUserGroupMemberResponse `json:"items" description:"成员列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// BusinessUserGroupMemberCandidateRequest 业务用户组成员候选分页查询请求。
type BusinessUserGroupMemberCandidateRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按用户名或脱敏手机号模糊搜索,最多 100 字符"`
GroupFilter string `json:"group_filter" query:"group_filter" validate:"omitempty,oneof=all ungrouped current other" enum:"all,ungrouped,current,other" description:"归属筛选 (all:全部, ungrouped:未分组, current:当前组, other:其他组)"`
}
// BusinessUserGroupMemberCandidateParams 业务用户组成员候选路径与查询参数。
type BusinessUserGroupMemberCandidateParams struct {
BusinessUserGroupMemberRequest
BusinessUserGroupMemberCandidateRequest
}
// BusinessUserGroupMemberCandidateResponse 业务用户组成员候选响应。
type BusinessUserGroupMemberCandidateResponse struct {
AccountID uint `json:"account_id" description:"平台用户账号ID"`
Username string `json:"username" description:"平台用户用户名"`
Phone string `json:"phone" description:"脱敏手机号仅保留前3位和后4位"`
CurrentGroupID *uint `json:"current_group_id" description:"当前业务用户组ID未分组时为空"`
CurrentGroupCode string `json:"current_group_code" description:"当前业务用户组稳定编码"`
CurrentGroupName string `json:"current_group_name" description:"当前业务用户组名称"`
CurrentGroupEnabled bool `json:"current_group_enabled" description:"当前业务用户组是否启用"`
Action string `json:"action" enum:"add,already_member,move" description:"稳定操作提示 (add:新增, already_member:已在目标组, move:迁入)"`
CanAdd bool `json:"can_add" description:"当前目标组是否允许新增成员"`
}
// BusinessUserGroupMemberCandidatePageResult 业务用户组成员候选分页响应。
type BusinessUserGroupMemberCandidatePageResult struct {
Items []*BusinessUserGroupMemberCandidateResponse `json:"items" description:"成员候选列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// AddBusinessUserGroupMembersRequest 增量增加业务用户组成员请求。
// account_ids 去重后必须非空,且全部是启用且未删除的平台用户账号。
type AddBusinessUserGroupMembersRequest struct {
AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表去重后至少一个全部必须是启用且未删除的平台用户"`
}
// RemoveBusinessUserGroupMembersRequest 组作用域增量移除成员请求。
type RemoveBusinessUserGroupMembersRequest struct {
AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"当前业务用户组成员账号ID列表去重后至少一个任一不属于目标组时整批拒绝"`
}
// BusinessUserGroupMemberRequest 业务用户组成员维护路径参数。
type BusinessUserGroupMemberRequest struct {
ID uint `path:"id" required:"true" description:"业务用户组ID"`
ID uint `json:"-" path:"id" validate:"required,min=1" required:"true" description:"业务用户组ID"`
}
// BusinessUserGroupDeleteRequest 业务用户组删除二次确认请求。
@@ -91,18 +164,21 @@ type BusinessUserGroupDeleteParams struct {
BusinessUserGroupDeleteRequest
}
// ClearBusinessUserGroupMembersParams 清空平台用户分组归属请求(用于 DELETE 显式请求体)
type ClearBusinessUserGroupMembersParams struct {
ClearBusinessUserGroupMembersRequest
// BusinessUserGroupMemberMutationResult 成员增量维护结果
type BusinessUserGroupMemberMutationResult struct {
GroupID uint `json:"group_id" description:"目标业务用户组ID"`
AccountIDs []uint `json:"account_ids" description:"本次请求去重后的账号ID列表"`
RequestedCount int `json:"requested_count" description:"本次请求账号数"`
AddedCount int `json:"added_count" description:"实际新增成员数"`
AddedAccountIDs []uint `json:"added_account_ids" description:"实际新增成员账号ID集合"`
MovedCount int `json:"moved_count" description:"从其他组迁入的成员数"`
MovedAccountIDs []uint `json:"moved_account_ids" description:"从其他组迁入的成员账号ID集合"`
UnchangedCount int `json:"unchanged_count" description:"已在目标组而未变化的成员数"`
UnchangedAccountIDs []uint `json:"unchanged_account_ids" description:"已在目标组而未变化的成员账号ID集合"`
RemovedCount int `json:"removed_count" description:"实际移除成员数"`
RemovedAccountIDs []uint `json:"removed_account_ids" description:"实际移除成员账号ID集合"`
}
// BusinessUserGroupMembersResult 成员维护结果。
type BusinessUserGroupMembersResult struct {
GroupID uint `json:"group_id" description:"目标业务用户组ID清空操作为 0"`
AccountIDs []uint `json:"account_ids" description:"本次成功维护归属的平台用户账号ID列表"`
}
// BatchUpdateShopBusinessOwnerRequest 勾选店铺批量设置或清空负责人请求。
// business_owner_account_id 缺失时请求非法;显式 null 表示清空负责人。
type BatchUpdateShopBusinessOwnerRequest struct {
ShopIDs []uint `json:"shop_ids" validate:"required,min=1,max=500,dive,min=1" required:"true" minItems:"1" maxItems:"500" description:"店铺ID列表至少一个且去重最多 500 个"`

View File

@@ -14,9 +14,10 @@ import (
"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/sanitizer"
)
// Query 查询业务用户组。
// Query 查询业务用户组及其成员
type Query struct {
db *gorm.DB
store *postgres.BusinessUserGroupStore
@@ -43,7 +44,7 @@ func (q *Query) List(ctx context.Context, request dto.BusinessUserGroupListReque
}
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("name LIKE ? OR code LIKE ?", like, like)
query = query.Where("name ILIKE ? OR code ILIKE ?", like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -54,9 +55,15 @@ func (q *Query) List(ctx context.Context, request dto.BusinessUserGroupListReque
Offset((page - 1) * pageSize).Limit(pageSize).Find(&groups).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组列表失败")
}
counts, err := q.memberCounts(ctx, groupIDs(groups))
if err != nil {
return nil, err
}
items := make([]*dto.BusinessUserGroupResponse, 0, len(groups))
for _, group := range groups {
items = append(items, toResponse(group))
item := toResponse(group)
item.MemberCount = counts[group.ID]
items = append(items, item)
}
return &dto.BusinessUserGroupPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
@@ -76,7 +83,180 @@ func (q *Query) Detail(ctx context.Context, groupID uint) (*dto.BusinessUserGrou
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
}
return toResponse(group), nil
count, err := q.store.CountMembers(ctx, groupID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败")
}
response := toResponse(group)
response.MemberCount = count
return response, nil
}
// ListMembers 分页查询指定业务用户组保留的全部成员关系。
func (q *Query) ListMembers(ctx context.Context, groupID uint, request dto.BusinessUserGroupMemberListRequest) (*dto.BusinessUserGroupMemberPageResult, error) {
if q == nil || q.db == nil || q.store == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组成员查询尚未配置")
}
if groupID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, err := q.store.GetByID(ctx, groupID); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "业务用户组不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
}
page, pageSize := normalizePage(request.Page, request.PageSize)
base := q.db.WithContext(ctx).Unscoped().Model(&model.BusinessUserGroupMember{}).
Joins("JOIN tb_account a ON a.id = tb_business_user_group_member.account_id").
Joins("JOIN tb_business_user_group g ON g.id = tb_business_user_group_member.business_user_group_id").
Where("tb_business_user_group_member.business_user_group_id = ? AND tb_business_user_group_member.deleted_at IS NULL AND g.deleted_at IS NULL", groupID)
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
like := "%" + keyword + "%"
base = base.Where("a.username ILIKE ? OR a.phone ILIKE ?", like, like)
}
switch request.AccountStatus {
case "enabled":
base = base.Where("a.deleted_at IS NULL AND a.status = ?", constants.StatusEnabled)
case "disabled":
base = base.Where("a.deleted_at IS NULL AND a.status = ?", constants.StatusDisabled)
case "deleted":
base = base.Where("a.deleted_at IS NOT NULL")
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组成员总数失败")
}
var rows []memberRow
if err := base.Select("tb_business_user_group_member.account_id, a.username, a.phone, a.status, a.deleted_at, tb_business_user_group_member.created_at AS member_created_at, g.id AS group_id, g.code AS group_code, g.name AS group_name, g.status AS group_status").
Order("tb_business_user_group_member.account_id ASC").
Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组成员失败")
}
items := make([]*dto.BusinessUserGroupMemberResponse, 0, len(rows))
for _, row := range rows {
items = append(items, &dto.BusinessUserGroupMemberResponse{
AccountID: row.AccountID, Username: row.Username, Phone: sanitizer.MaskPhone(row.Phone),
Enabled: row.Status == constants.StatusEnabled && !row.DeletedAt.Valid,
StatusName: constants.GetStatusName(row.Status), Deleted: row.DeletedAt.Valid,
MemberCreatedAt: row.MemberCreatedAt.Format(time.RFC3339), CurrentGroupID: row.GroupID,
CurrentGroupCode: row.GroupCode, CurrentGroupName: row.GroupName,
CurrentGroupEnabled: row.GroupStatus == constants.StatusEnabled,
})
}
return &dto.BusinessUserGroupMemberPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// ListMemberCandidates 分页查询指定业务用户组的启用平台用户候选。
func (q *Query) ListMemberCandidates(ctx context.Context, groupID uint, request dto.BusinessUserGroupMemberCandidateRequest) (*dto.BusinessUserGroupMemberCandidatePageResult, error) {
if q == nil || q.db == nil || q.store == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组候选查询尚未配置")
}
if groupID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
group, err := q.store.GetByID(ctx, groupID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "业务用户组不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
}
page, pageSize := normalizePage(request.Page, request.PageSize)
base := q.db.WithContext(ctx).Model(&model.Account{}).
Where("tb_account.user_type = ? AND tb_account.status = ?", constants.UserTypePlatform, constants.StatusEnabled).
Joins("LEFT JOIN tb_business_user_group_member m ON m.account_id = tb_account.id AND m.deleted_at IS NULL").
Joins("LEFT JOIN tb_business_user_group g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL")
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
like := "%" + keyword + "%"
base = base.Where("tb_account.username ILIKE ? OR tb_account.phone ILIKE ?", like, like)
}
switch request.GroupFilter {
case "ungrouped":
base = base.Where("m.account_id IS NULL")
case "current":
base = base.Where("m.business_user_group_id = ?", groupID)
case "other":
base = base.Where("m.business_user_group_id IS NOT NULL AND m.business_user_group_id <> ?", groupID)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组候选总数失败")
}
var rows []candidateRow
if err := base.Select("tb_account.id AS account_id, tb_account.username, tb_account.phone, m.business_user_group_id AS group_id, g.code AS group_code, g.name AS group_name, g.status AS group_status").
Order("tb_account.id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组候选失败")
}
canAdd := group.Status == constants.StatusEnabled
items := make([]*dto.BusinessUserGroupMemberCandidateResponse, 0, len(rows))
for _, row := range rows {
action := "add"
if row.GroupID != nil {
action = "move"
if *row.GroupID == groupID {
action = "already_member"
}
}
items = append(items, &dto.BusinessUserGroupMemberCandidateResponse{
AccountID: row.AccountID, Username: row.Username, Phone: sanitizer.MaskPhone(row.Phone),
CurrentGroupID: row.GroupID, CurrentGroupCode: row.GroupCode, CurrentGroupName: row.GroupName,
CurrentGroupEnabled: row.GroupStatus != nil && *row.GroupStatus == constants.StatusEnabled,
Action: action, CanAdd: canAdd,
})
}
return &dto.BusinessUserGroupMemberCandidatePageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
type memberRow struct {
AccountID uint
Username string
Phone string
Status int
DeletedAt gorm.DeletedAt
MemberCreatedAt time.Time
GroupID uint `gorm:"column:group_id"`
GroupCode string
GroupName string
GroupStatus int
}
type candidateRow struct {
AccountID uint
Username string
Phone string
GroupID *uint
GroupCode string
GroupName string
GroupStatus *int
}
func (q *Query) memberCounts(ctx context.Context, ids []uint) (map[uint]int64, error) {
counts := make(map[uint]int64, len(ids))
if len(ids) == 0 {
return counts, nil
}
var rows []struct {
GroupID uint
Count int64
}
if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).
Select("business_user_group_id AS group_id, COUNT(*) AS count").
Where("business_user_group_id IN ?", ids).Group("business_user_group_id").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败")
}
for _, row := range rows {
counts[row.GroupID] = row.Count
}
return counts, nil
}
func groupIDs(groups []*model.BusinessUserGroup) []uint {
ids := make([]uint, 0, len(groups))
for _, group := range groups {
ids = append(ids, group.ID)
}
return ids
}
// toResponse 将业务用户组投影为对外响应。

View File

@@ -60,13 +60,22 @@ func registerBusinessUserGroupRoutes(router fiber.Router, handler *admin.Busines
Auth: true,
})
// 批量清空归属使用静态路径 /members必须先于动态 /:id 注册,否则会被 :id 吞掉
Register(groups, doc, groupPath, "DELETE", "/members", handler.ClearMembers, RouteSpec{
Summary: "批量清空平台用户业务用户组归属",
Description: "仅超级管理员和平台账号可操作。与批量设置使用同一校验:所有账号必须是启用平台用户,任一账号无效则全量回滚。清空后账号回到未分组。",
// 成员查询路径必须先于动态组详情注册,避免动态参数吞掉子路径
Register(groups, doc, groupPath, "GET", "/:id/members", handler.ListMembers, RouteSpec{
Summary: "查询业务用户组成员",
Description: "仅超级管理员和平台账号可操作。分页返回目标组保留的启用、禁用及已删除账号关系,支持用户名或脱敏手机号关键字及账号状态筛选。",
Tags: []string{"业务用户组"},
Input: new(dto.ClearBusinessUserGroupMembersParams),
Output: new(dto.BusinessUserGroupMembersResult),
Input: new(dto.BusinessUserGroupMemberListParams),
Output: new(dto.BusinessUserGroupMemberPageResult),
Auth: true,
})
Register(groups, doc, groupPath, "GET", "/:id/member-candidates", handler.ListMemberCandidates, RouteSpec{
Summary: "查询业务用户组成员候选",
Description: "仅超级管理员和平台账号可操作。分页返回启用且未删除的平台用户及当前归属,手机号仅返回脱敏值;支持全部、未分组、当前组和其他组筛选,停用目标组不可新增。",
Tags: []string{"业务用户组"},
Input: new(dto.BusinessUserGroupMemberCandidateParams),
Output: new(dto.BusinessUserGroupMemberCandidatePageResult),
Auth: true,
})
@@ -98,13 +107,23 @@ func registerBusinessUserGroupRoutes(router fiber.Router, handler *admin.Busines
Auth: true,
})
Register(groups, doc, groupPath, "PUT", "/:id/members", handler.SetMembers, RouteSpec{
Summary: "批量设置平台用户业务用户组归属",
Description: "仅超级管理员和平台账号可操作。请求为账号ID数组所有账号必须是启用平台用户且目标组启用;事务内直接替换每个账号原归属,任一账号无效则全量回滚。停用组不得作为目标,已有成员关系保留并显示已停用。",
Register(groups, doc, groupPath, "POST", "/:id/members", handler.AddMembers, RouteSpec{
Summary: "增量增加业务用户组成员",
Description: "仅超级管理员和平台账号可操作。目标组必须启用;未分组账号新增、其他组账号直接迁入、当前组账号幂等不变;任一无效账号整批回滚。",
Tags: []string{"业务用户组"},
Input: new(dto.BusinessUserGroupMemberRequest),
Body: new(dto.SetBusinessUserGroupMembersRequest),
Output: new(dto.BusinessUserGroupMembersResult),
Body: new(dto.AddBusinessUserGroupMembersRequest),
Output: new(dto.BusinessUserGroupMemberMutationResult),
Auth: true,
})
Register(groups, doc, groupPath, "DELETE", "/:id/members", handler.RemoveMembers, RouteSpec{
Summary: "增量移除业务用户组成员",
Description: "仅超级管理员和平台账号可操作。目标组启用或停用均可移除;请求账号必须全部属于目标组,禁用或已删除账号关系也可清理,任一不匹配整批冲突。",
Tags: []string{"业务用户组"},
Input: new(dto.BusinessUserGroupMemberRequest),
Body: new(dto.RemoveBusinessUserGroupMembersRequest),
Output: new(dto.BusinessUserGroupMemberMutationResult),
Auth: true,
})
}

View File

@@ -151,12 +151,16 @@ func (s *BusinessUserGroupStore) ReplaceMemberGroup(ctx context.Context, account
return s.db.WithContext(ctx).Create(&missing).Error
}
// ClearMembers 删除给定账号的未删除成员关系,使账号回到未分组
func (s *BusinessUserGroupStore) ClearMembers(ctx context.Context, accountIDs []uint) error {
// RemoveMembersByGroup 按目标组和账号集合软删除成员关系,并返回实际影响行数
func (s *BusinessUserGroupStore) RemoveMembersByGroup(ctx context.Context, groupID uint, accountIDs []uint, operatorID uint) (int64, error) {
if len(accountIDs) == 0 {
return nil
return 0, nil
}
return s.db.WithContext(ctx).Where("account_id IN ?", accountIDs).Delete(&model.BusinessUserGroupMember{}).Error
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).
Where("business_user_group_id = ? AND account_id IN ? AND deleted_at IS NULL", groupID, accountIDs).
Updates(map[string]any{"deleted_at": now, "updater": operatorID, "updated_at": now})
return result.RowsAffected, result.Error
}
// LockAccountsByIDs 在事务内对账号行本身按主键升序加行锁。
@@ -168,7 +172,7 @@ func (s *BusinessUserGroupStore) LockAccountsByIDs(ctx context.Context, accountI
return nil
}
var accounts []model.Account
return s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
return s.db.WithContext(ctx).Unscoped().Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").Where("id IN ?", accountIDs).Order("id ASC").Find(&accounts).Error
}