实施业务用户组成员管理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m42s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m42s
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 个"`
|
||||
|
||||
@@ -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 将业务用户组投影为对外响应。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user