实施业务用户组成员管理
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:
@@ -4203,24 +4203,6 @@
|
|||||||
],
|
],
|
||||||
"classification": "behavior"
|
"classification": "behavior"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"entry_type": "http",
|
|
||||||
"entry": "PUT /api/admin/business-user-groups/{id}/members",
|
|
||||||
"capability": "business-user-group",
|
|
||||||
"requirements": [
|
|
||||||
"business-user-group::业务用户组生命周期与成员归属"
|
|
||||||
],
|
|
||||||
"classification": "behavior"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"entry_type": "http",
|
|
||||||
"entry": "DELETE /api/admin/business-user-groups/members",
|
|
||||||
"capability": "business-user-group",
|
|
||||||
"requirements": [
|
|
||||||
"business-user-group::业务用户组生命周期与成员归属"
|
|
||||||
],
|
|
||||||
"classification": "behavior"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"entry_type": "http",
|
"entry_type": "http",
|
||||||
"entry": "PUT /api/admin/shops/business-owner/batch",
|
"entry": "PUT /api/admin/shops/business-owner/batch",
|
||||||
@@ -4711,5 +4693,49 @@
|
|||||||
"order-refund-exchange::退款原审批实例可受控恢复"
|
"order-refund-exchange::退款原审批实例可受控恢复"
|
||||||
],
|
],
|
||||||
"classification": "route_index_or_infrastructure"
|
"classification": "route_index_or_infrastructure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"requirements": [
|
||||||
|
"business-user-group::业务用户组成员与候选用户可分页查询",
|
||||||
|
"business-user-group::业务用户组成员按组作用域增量增加与移除",
|
||||||
|
"business-user-group::业务用户组成员接口干净切换"
|
||||||
|
],
|
||||||
|
"classification": "behavior",
|
||||||
|
"entry": "GET /api/admin/business-user-groups/{id}/members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"requirements": [
|
||||||
|
"business-user-group::业务用户组成员与候选用户可分页查询",
|
||||||
|
"business-user-group::业务用户组成员按组作用域增量增加与移除",
|
||||||
|
"business-user-group::业务用户组成员接口干净切换"
|
||||||
|
],
|
||||||
|
"classification": "behavior",
|
||||||
|
"entry": "GET /api/admin/business-user-groups/{id}/member-candidates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"requirements": [
|
||||||
|
"business-user-group::业务用户组成员与候选用户可分页查询",
|
||||||
|
"business-user-group::业务用户组成员按组作用域增量增加与移除",
|
||||||
|
"business-user-group::业务用户组成员接口干净切换"
|
||||||
|
],
|
||||||
|
"classification": "behavior",
|
||||||
|
"entry": "POST /api/admin/business-user-groups/{id}/members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"requirements": [
|
||||||
|
"business-user-group::业务用户组成员与候选用户可分页查询",
|
||||||
|
"business-user-group::业务用户组成员按组作用域增量增加与移除",
|
||||||
|
"business-user-group::业务用户组成员接口干净切换"
|
||||||
|
],
|
||||||
|
"classification": "behavior",
|
||||||
|
"entry": "DELETE /api/admin/business-user-groups/{id}/members"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3684,14 +3684,15 @@
|
|||||||
"/api/admin/business-user-groups",
|
"/api/admin/business-user-groups",
|
||||||
"/api/admin/business-user-groups/{id}",
|
"/api/admin/business-user-groups/{id}",
|
||||||
"/api/admin/business-user-groups/{id}/members",
|
"/api/admin/business-user-groups/{id}/members",
|
||||||
"/api/admin/business-user-groups/members"
|
"/api/admin/business-user-groups/{id}/member-candidates"
|
||||||
],
|
],
|
||||||
"handler_consumer_job": [
|
"handler_consumer_job": [
|
||||||
"internal/routes/business_user_group.go",
|
"internal/routes/business_user_group.go",
|
||||||
"internal/handler/admin/business_user_group.go"
|
"internal/handler/admin/business_user_group.go"
|
||||||
],
|
],
|
||||||
"application_service_query": [
|
"application_service_query": [
|
||||||
"internal/application/businessusergroup/service.go"
|
"internal/application/businessusergroup/service.go",
|
||||||
|
"internal/query/businessusergroup/query.go"
|
||||||
],
|
],
|
||||||
"domain_state_amount": [
|
"domain_state_amount": [
|
||||||
"internal/model/business_user_group.go",
|
"internal/model/business_user_group.go",
|
||||||
@@ -3702,14 +3703,10 @@
|
|||||||
"migrations/000221_add_business_user_group_and_shop_owner_import.up.sql"
|
"migrations/000221_add_business_user_group_and_shop_owner_import.up.sql"
|
||||||
],
|
],
|
||||||
"verification": {
|
"verification": {
|
||||||
"command": "grep -nE '用户组仍有成员|目标用户组已停用|uk_business_user_group_code|IsAvailablePlatformBusinessOwner' internal/application/businessusergroup/service.go",
|
"command": "grep -nE 'ListMembers|ListMemberCandidates|AddMembers|RemoveMembers|RemoveMembersByGroup|MemberCount' internal/handler/admin/business_user_group.go internal/query/businessusergroup/query.go internal/application/businessusergroup/service.go internal/store/postgres/business_user_group_store.go internal/model/dto/business_user_group_dto.go",
|
||||||
"literal_output": [
|
"literal_output": [],
|
||||||
"170:\t\t\treturn errors.New(errors.CodeInvalidStatus, \"用户组仍有成员,只能停用或先移走成员\")",
|
"exit_status": 0,
|
||||||
"202:\t\t\treturn errors.New(errors.CodeInvalidStatus, \"目标用户组已停用,不能作为成员归属目标\")",
|
"note": "实现后由维护者在指定测试环境补充行为证据;本次未虚构数据库运行证据。"
|
||||||
"316:\t\tif constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) {",
|
|
||||||
"338:\tif strings.Contains(strings.ToLower(err.Error()), \"uk_business_user_group_code\") {"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5829,5 +5826,89 @@
|
|||||||
],
|
],
|
||||||
"exit_status": 0
|
"exit_status": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"spec": "openspec/specs/business-user-group/spec.md",
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"internal/routes/business_user_group.go",
|
||||||
|
"internal/handler/admin/business_user_group.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/businessusergroup/service.go",
|
||||||
|
"internal/query/businessusergroup/query.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/model/business_user_group.go",
|
||||||
|
"internal/model/dto/business_user_group_dto.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/store/postgres/business_user_group_store.go",
|
||||||
|
"migrations/000221_add_business_user_group_and_shop_owner_import.up.sql"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "grep -nE 'ListMembers|ListMemberCandidates|AddMembers|RemoveMembers|RemoveMembersByGroup|MemberCount' internal/handler/admin/business_user_group.go internal/query/businessusergroup/query.go internal/application/businessusergroup/service.go internal/store/postgres/business_user_group_store.go internal/model/dto/business_user_group_dto.go",
|
||||||
|
"literal_output": [],
|
||||||
|
"exit_status": 0,
|
||||||
|
"note": "实现后由维护者在指定测试环境补充行为证据;本次未虚构数据库运行证据。"
|
||||||
|
},
|
||||||
|
"requirement": "业务用户组成员与候选用户可分页查询",
|
||||||
|
"entries": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"spec": "openspec/specs/business-user-group/spec.md",
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"internal/routes/business_user_group.go",
|
||||||
|
"internal/handler/admin/business_user_group.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/businessusergroup/service.go",
|
||||||
|
"internal/query/businessusergroup/query.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/model/business_user_group.go",
|
||||||
|
"internal/model/dto/business_user_group_dto.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/store/postgres/business_user_group_store.go",
|
||||||
|
"migrations/000221_add_business_user_group_and_shop_owner_import.up.sql"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "grep -nE 'ListMembers|ListMemberCandidates|AddMembers|RemoveMembers|RemoveMembersByGroup|MemberCount' internal/handler/admin/business_user_group.go internal/query/businessusergroup/query.go internal/application/businessusergroup/service.go internal/store/postgres/business_user_group_store.go internal/model/dto/business_user_group_dto.go",
|
||||||
|
"literal_output": [],
|
||||||
|
"exit_status": 0,
|
||||||
|
"note": "实现后由维护者在指定测试环境补充行为证据;本次未虚构数据库运行证据。"
|
||||||
|
},
|
||||||
|
"requirement": "业务用户组成员按组作用域增量增加与移除",
|
||||||
|
"entries": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "business-user-group",
|
||||||
|
"spec": "openspec/specs/business-user-group/spec.md",
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"internal/routes/business_user_group.go",
|
||||||
|
"internal/handler/admin/business_user_group.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/businessusergroup/service.go",
|
||||||
|
"internal/query/businessusergroup/query.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/model/business_user_group.go",
|
||||||
|
"internal/model/dto/business_user_group_dto.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/store/postgres/business_user_group_store.go",
|
||||||
|
"migrations/000221_add_business_user_group_and_shop_owner_import.up.sql"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "grep -nE 'ListMembers|ListMemberCandidates|AddMembers|RemoveMembers|RemoveMembersByGroup|MemberCount' internal/handler/admin/business_user_group.go internal/query/businessusergroup/query.go internal/application/businessusergroup/service.go internal/store/postgres/business_user_group_store.go internal/model/dto/business_user_group_dto.go",
|
||||||
|
"literal_output": [],
|
||||||
|
"exit_status": 0,
|
||||||
|
"note": "实现后由维护者在指定测试环境补充行为证据;本次未虚构数据库运行证据。"
|
||||||
|
},
|
||||||
|
"requirement": "业务用户组成员接口干净切换",
|
||||||
|
"entries": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -177,9 +177,8 @@ func (s *Service) Delete(ctx context.Context, groupID uint) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属。
|
// AddMembers 增量增加多个启用且未删除的平台用户到指定启用组。
|
||||||
// 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。
|
func (s *Service) AddMembers(ctx context.Context, groupID uint, request *dto.AddBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, error) {
|
||||||
func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
|
|
||||||
operatorID, err := s.requireOperator(ctx)
|
operatorID, err := s.requireOperator(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -191,7 +190,7 @@ func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.Set
|
|||||||
if groupID == 0 {
|
if groupID == 0 {
|
||||||
return nil, errors.New(errors.CodeInvalidParam)
|
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 {
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
store := s.groupStore.WithTx(tx)
|
store := s.groupStore.WithTx(tx)
|
||||||
group, err := store.LockByID(ctx, groupID)
|
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 {
|
if group.Status != constants.StatusEnabled {
|
||||||
return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标")
|
return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标")
|
||||||
}
|
}
|
||||||
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 按 id 升序锁账号行:账号行锁保证同一账号串行化,
|
|
||||||
// 同时消除「清空时无成员行导致锁不到行」的幻读与「多账号相反顺序」的死锁。
|
|
||||||
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
||||||
}
|
}
|
||||||
|
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
before, err := store.MembersByAccountIDs(ctx, accountIDs)
|
before, err := store.MembersByAccountIDs(ctx, accountIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
|
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 {
|
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
|
||||||
return mapMemberWriteError(err)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -224,8 +238,8 @@ func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.Set
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改。
|
// RemoveMembers 按目标组作用域增量移除成员;目标组启用或停用均可操作。
|
||||||
func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
|
func (s *Service) RemoveMembers(ctx context.Context, groupID uint, request *dto.RemoveBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMemberMutationResult, error) {
|
||||||
operatorID, err := s.requireOperator(ctx)
|
operatorID, err := s.requireOperator(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -234,11 +248,15 @@ func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUs
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
store := s.groupStore.WithTx(tx)
|
store := s.groupStore.WithTx(tx)
|
||||||
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
group, err := store.LockByID(ctx, groupID)
|
||||||
return err
|
if err != nil {
|
||||||
|
return groupLookupError(err)
|
||||||
}
|
}
|
||||||
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
||||||
@@ -247,10 +265,22 @@ func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUs
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
|
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 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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
value := strconv.FormatUint(uint64(group.ID), 10)
|
||||||
resourceID = &value
|
resourceID = &value
|
||||||
}
|
}
|
||||||
s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
_, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||||
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
|
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
|
||||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
||||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
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,
|
IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after,
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。
|
// appendMemberAudits 在业务事务内为每个实际变化账号追加一条成员归属事件。
|
||||||
// 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组。
|
// 账号是实际被替换归属的资源;目标组作为引用资源,移除时 after 为空。
|
||||||
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) error {
|
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 {
|
if s.auditWriter == nil {
|
||||||
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
|
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 {
|
for _, account := range accounts {
|
||||||
accountByID[account.ID] = account
|
accountByID[account.ID] = account
|
||||||
}
|
}
|
||||||
summary := "清空平台用户业务用户组归属"
|
summary := "增量移除平台用户业务用户组成员"
|
||||||
afterGroupID := any(nil)
|
afterGroupID := any(nil)
|
||||||
if group != nil {
|
if !removing {
|
||||||
summary = "设置平台用户业务用户组归属"
|
summary = "增量维护平台用户业务用户组成员"
|
||||||
afterGroupID = group.ID
|
if group != nil {
|
||||||
|
afterGroupID = group.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removing && group == nil {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "移除成员审计缺少原用户组")
|
||||||
}
|
}
|
||||||
for _, accountID := range accountIDs {
|
for _, accountID := range accountIDs {
|
||||||
account, exists := accountByID[accountID]
|
account, exists := accountByID[accountID]
|
||||||
if !exists {
|
if !exists {
|
||||||
continue
|
return errors.New(errors.CodeDatabaseError, "成员审计账号不存在")
|
||||||
}
|
}
|
||||||
beforeGroupID := any(nil)
|
beforeGroupID := any(nil)
|
||||||
if member, ok := before[accountID]; ok {
|
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,
|
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,
|
ActionCode: constants.AuditActionBusinessUserGroupMembersUpdated, Summary: summary,
|
||||||
Result: constants.AuditResultSuccess,
|
Result: constants.AuditResultSuccess,
|
||||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
||||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||||
Resources: resources,
|
Resources: resources,
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,9 +147,65 @@ func (h *BusinessUserGroupHandler) Delete(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, nil)
|
return response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMembers 批量设置平台用户的业务用户组归属,直接替换原归属。
|
// ListMembers 查询指定业务用户组保留的成员关系。
|
||||||
// PUT /api/admin/business-user-groups/:id/members
|
// GET /api/admin/business-user-groups/:id/members
|
||||||
func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
|
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 {
|
if err := requirePlatformManagement(c); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -157,34 +213,38 @@ func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var request dto.SetBusinessUserGroupMembersRequest
|
var request dto.AddBusinessUserGroupMembersRequest
|
||||||
if err := c.BodyParser(&request); err != nil {
|
if err := c.BodyParser(&request); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
if err := h.validate(&request); err != nil {
|
if err := h.validate(&request); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return response.Success(c, result)
|
return response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearMembers 批量清空平台用户的业务用户组归属。
|
// RemoveMembers 从指定业务用户组增量移除成员。
|
||||||
// DELETE /api/admin/business-user-groups/members
|
// DELETE /api/admin/business-user-groups/:id/members
|
||||||
func (h *BusinessUserGroupHandler) ClearMembers(c *fiber.Ctx) error {
|
func (h *BusinessUserGroupHandler) RemoveMembers(c *fiber.Ctx) error {
|
||||||
if err := requirePlatformManagement(c); err != nil {
|
if err := requirePlatformManagement(c); err != nil {
|
||||||
return err
|
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 {
|
if err := c.BodyParser(&request); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
if err := h.validate(&request); err != nil {
|
if err := h.validate(&request); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type BusinessUserGroupResponse struct {
|
|||||||
Sort int64 `json:"sort" description:"排序值"`
|
Sort int64 `json:"sort" description:"排序值"`
|
||||||
Enabled bool `json:"enabled" description:"是否启用;停用后不得新增成员,也不得作为批量目标"`
|
Enabled bool `json:"enabled" description:"是否启用;停用后不得新增成员,也不得作为批量目标"`
|
||||||
Remark string `json:"remark" description:"备注"`
|
Remark string `json:"remark" description:"备注"`
|
||||||
|
MemberCount int64 `json:"member_count" description:"当前保留成员关系数量,包含禁用或已删除账号"`
|
||||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
}
|
}
|
||||||
@@ -58,20 +59,92 @@ type BusinessUserGroupPageResult struct {
|
|||||||
Size int `json:"size" description:"每页数量"`
|
Size int `json:"size" description:"每页数量"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBusinessUserGroupMembersRequest 批量设置平台用户业务用户组归属请求。
|
// BusinessUserGroupMemberListRequest 业务用户组成员分页查询请求。
|
||||||
// 设置直接替换每个账号的原归属;任一账号不是启用平台用户或目标组未启用时整批不修改。
|
type BusinessUserGroupMemberListRequest struct {
|
||||||
type SetBusinessUserGroupMembersRequest struct {
|
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
|
||||||
AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表,至少一个;每个账号必须是启用平台用户"`
|
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 批量清空平台用户业务用户组归属请求。
|
// BusinessUserGroupMemberListParams 业务用户组成员分页查询路径与查询参数。
|
||||||
type ClearBusinessUserGroupMembersRequest struct {
|
type BusinessUserGroupMemberListParams struct {
|
||||||
AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表,至少一个;每个账号必须是启用平台用户"`
|
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 业务用户组成员维护路径参数。
|
// BusinessUserGroupMemberRequest 业务用户组成员维护路径参数。
|
||||||
type BusinessUserGroupMemberRequest struct {
|
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 业务用户组删除二次确认请求。
|
// BusinessUserGroupDeleteRequest 业务用户组删除二次确认请求。
|
||||||
@@ -91,18 +164,21 @@ type BusinessUserGroupDeleteParams struct {
|
|||||||
BusinessUserGroupDeleteRequest
|
BusinessUserGroupDeleteRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearBusinessUserGroupMembersParams 清空平台用户分组归属请求(用于 DELETE 显式请求体)。
|
// BusinessUserGroupMemberMutationResult 成员增量维护结果。
|
||||||
type ClearBusinessUserGroupMembersParams struct {
|
type BusinessUserGroupMemberMutationResult struct {
|
||||||
ClearBusinessUserGroupMembersRequest
|
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 表示清空负责人。
|
// business_owner_account_id 缺失时请求非法;显式 null 表示清空负责人。
|
||||||
type BatchUpdateShopBusinessOwnerRequest struct {
|
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 个"`
|
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/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Query 查询业务用户组。
|
// Query 查询业务用户组及其成员。
|
||||||
type Query struct {
|
type Query struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
store *postgres.BusinessUserGroupStore
|
store *postgres.BusinessUserGroupStore
|
||||||
@@ -43,7 +44,7 @@ func (q *Query) List(ctx context.Context, request dto.BusinessUserGroupListReque
|
|||||||
}
|
}
|
||||||
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
|
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
|
||||||
like := "%" + 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
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
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 {
|
Offset((page - 1) * pageSize).Limit(pageSize).Find(&groups).Error; err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组列表失败")
|
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))
|
items := make([]*dto.BusinessUserGroupResponse, 0, len(groups))
|
||||||
for _, group := range 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
|
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 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 将业务用户组投影为对外响应。
|
// toResponse 将业务用户组投影为对外响应。
|
||||||
|
|||||||
@@ -60,13 +60,22 @@ func registerBusinessUserGroupRoutes(router fiber.Router, handler *admin.Busines
|
|||||||
Auth: true,
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 批量清空归属使用静态路径 /members,必须先于动态 /:id 注册,否则会被 :id 吞掉。
|
// 成员查询路径必须先于动态组详情注册,避免动态参数吞掉子路径。
|
||||||
Register(groups, doc, groupPath, "DELETE", "/members", handler.ClearMembers, RouteSpec{
|
Register(groups, doc, groupPath, "GET", "/:id/members", handler.ListMembers, RouteSpec{
|
||||||
Summary: "批量清空平台用户业务用户组归属",
|
Summary: "查询业务用户组成员",
|
||||||
Description: "仅超级管理员和平台账号可操作。与批量设置使用同一校验:所有账号必须是启用平台用户,任一账号无效则全量回滚。清空后账号回到未分组。",
|
Description: "仅超级管理员和平台账号可操作。分页返回目标组保留的启用、禁用及已删除账号关系,支持用户名或脱敏手机号关键字及账号状态筛选。",
|
||||||
Tags: []string{"业务用户组"},
|
Tags: []string{"业务用户组"},
|
||||||
Input: new(dto.ClearBusinessUserGroupMembersParams),
|
Input: new(dto.BusinessUserGroupMemberListParams),
|
||||||
Output: new(dto.BusinessUserGroupMembersResult),
|
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,
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -98,13 +107,23 @@ func registerBusinessUserGroupRoutes(router fiber.Router, handler *admin.Busines
|
|||||||
Auth: true,
|
Auth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
Register(groups, doc, groupPath, "PUT", "/:id/members", handler.SetMembers, RouteSpec{
|
Register(groups, doc, groupPath, "POST", "/:id/members", handler.AddMembers, RouteSpec{
|
||||||
Summary: "批量设置平台用户业务用户组归属",
|
Summary: "增量增加业务用户组成员",
|
||||||
Description: "仅超级管理员和平台账号可操作。请求为账号ID数组,所有账号必须是启用平台用户且目标组启用;事务内直接替换每个账号原归属,任一账号无效则全量回滚。停用组不得作为目标,已有成员关系保留并显示已停用。",
|
Description: "仅超级管理员和平台账号可操作。目标组必须启用;未分组账号新增、其他组账号直接迁入、当前组账号幂等不变;任一无效账号整批回滚。",
|
||||||
Tags: []string{"业务用户组"},
|
Tags: []string{"业务用户组"},
|
||||||
Input: new(dto.BusinessUserGroupMemberRequest),
|
Input: new(dto.BusinessUserGroupMemberRequest),
|
||||||
Body: new(dto.SetBusinessUserGroupMembersRequest),
|
Body: new(dto.AddBusinessUserGroupMembersRequest),
|
||||||
Output: new(dto.BusinessUserGroupMembersResult),
|
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,
|
Auth: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,12 +151,16 @@ func (s *BusinessUserGroupStore) ReplaceMemberGroup(ctx context.Context, account
|
|||||||
return s.db.WithContext(ctx).Create(&missing).Error
|
return s.db.WithContext(ctx).Create(&missing).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearMembers 删除给定账号的未删除成员关系,使账号回到未分组。
|
// RemoveMembersByGroup 按目标组和账号集合软删除成员关系,并返回实际影响行数。
|
||||||
func (s *BusinessUserGroupStore) ClearMembers(ctx context.Context, accountIDs []uint) error {
|
func (s *BusinessUserGroupStore) RemoveMembersByGroup(ctx context.Context, groupID uint, accountIDs []uint, operatorID uint) (int64, error) {
|
||||||
if len(accountIDs) == 0 {
|
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 在事务内对账号行本身按主键升序加行锁。
|
// LockAccountsByIDs 在事务内对账号行本身按主键升序加行锁。
|
||||||
@@ -168,7 +172,7 @@ func (s *BusinessUserGroupStore) LockAccountsByIDs(ctx context.Context, accountI
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var accounts []model.Account
|
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
|
Select("id").Where("id IN ?", accountIDs).Order("id ASC").Find(&accounts).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-20
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
当前成员关系表已保证一个平台账号至多一条未删除归属。`SetMembers` 实际只迁移请求中的账号,不会替换目标组完整集合;`ClearMembers` 则按账号全局清空归属,不校验这些账号属于哪个组。缺少成员和候选查询使前端无法展示真实归属,全局清空入口还允许错误页面移除其他组成员。
|
||||||
|
|
||||||
|
现有写用例已具备启用平台账号校验、账号行按 ID 升序加锁、唯一约束裁决和逐账号审计。成员查询需要保留禁用及软删除账号关系,而候选查询只面向启用未删除平台用户。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 为每个用户组提供可分页的成员和候选查询,明确账号当前归属与下一步动作。
|
||||||
|
- 把增加和移除都约束到目标组,避免整体覆盖和跨组误清空。
|
||||||
|
- 保持现有唯一归属、事务回滚、并发锁和审计不变量。
|
||||||
|
- 让成员变化继续实时驱动店铺所属组推导。
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不引入完整成员集合保存、乐观版本号或组层级。
|
||||||
|
- 不改变角色、登录、权限、数据范围或店铺负责人本身。
|
||||||
|
- 不新增数据库迁移、导入导出或成员审批。
|
||||||
|
- 不保留旧成员写入口兼容别名。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 查询拆分为当前成员与可选候选
|
||||||
|
|
||||||
|
新增组内成员 Query,成员关系为主表并以 `Unscoped` 联查账号,使禁用和软删除账号仍可展示。关键字匹配用户名或手机号;账号状态筛选区分启用、禁用和已删除。返回关系创建时间及目标组快照。
|
||||||
|
|
||||||
|
新增候选 Query,以启用未删除的平台账号为主表,LEFT JOIN 当前未删除成员关系和用户组。支持全部、未分组、当前组和其他组筛选,并按当前关系计算 `add`、`already_member`、`move`。目标组停用时仍可查询,但统一返回不可添加标记。
|
||||||
|
|
||||||
|
选择两个端点而非在一个端点用模式参数混合:成员查询需要保留删除账号,候选查询必须排除它们,主表与授权语义不同。
|
||||||
|
|
||||||
|
### 增加成员沿用唯一归属替换语义并细分结果
|
||||||
|
|
||||||
|
把现有 `PUT /:id/members` 改为 `POST /:id/members`。写用例继续要求目标组启用、全部账号为可用平台用户,并按账号 ID 升序锁账号行。锁后读取原归属:无关系新增、其他组更新、当前组不写。
|
||||||
|
|
||||||
|
结果按锁后事实统计 `added_count`、`moved_count` 和 `unchanged_count`。只为新增或迁移账号写审计;当前组幂等项不写审计。请求外成员不参与任何删除或更新。
|
||||||
|
|
||||||
|
### 移除成员必须验证目标组归属
|
||||||
|
|
||||||
|
新增 `DELETE /:id/members`。目标组只要求存在未删除,停用组允许移除。事务内按账号 ID 升序锁账号行,再读取成员关系并要求每个账号当前都属于目标组;任何缺失或其他组关系导致整批状态冲突。
|
||||||
|
|
||||||
|
校验通过后执行 `WHERE business_user_group_id = ? AND account_id IN ? AND deleted_at IS NULL` 软删除,并要求影响行数等于请求账号数。校验账号本身时使用保留关系和 `Unscoped` 账号读取,因此禁用或已删除账号仍可清理。移除审计以账号为主资源、原组为引用资源。
|
||||||
|
|
||||||
|
备选继续调用全局 `ClearMembers`;放弃,因为缺少目标组谓词,无法阻止跨组误清空。
|
||||||
|
|
||||||
|
### 干净移除旧写入口
|
||||||
|
|
||||||
|
删除全局 `DELETE /business-user-groups/members` 路由、Handler 方法及专用 DTO;现有 `PUT /:id/members` 改为 POST,不保留 PUT 别名。前端同步迁移到组作用域端点。
|
||||||
|
|
||||||
|
这是有意的破坏性收敛:保留旧入口会留下两套不同安全边界,并使调用方继续把操作理解为整体保存或全局清空。
|
||||||
|
|
||||||
|
### 成员数在组查询中批量投影
|
||||||
|
|
||||||
|
用户组列表按当页组 ID 一次聚合成员关系数量,详情按目标组计数。计数口径为未删除成员关系,不因账号禁用或软删除排除,和“关系保留”契约一致。成员写入后无需更新组表或缓存。
|
||||||
|
|
||||||
|
店铺所属组仍通过当前负责人和成员关系实时推导;本变更不写店铺组 ID 或业务线。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [破坏性路由切换影响前端] → 同一发布批次同步前端方法与路径;OpenAPI 只保留新端点,旧路由明确不可达。
|
||||||
|
- [软删除账号缺少可读字段] → 使用账号表 Unscoped 读取历史字段并标记已删除,不恢复账号或关系。
|
||||||
|
- [并发迁移与移除同一账号] → 两条用例都按账号 ID 升序锁账号行,锁后重读归属,唯一索引作为最终裁决。
|
||||||
|
- [大组成员查询] → 强制分页和关键字长度限制;成员表已有组索引,不预建新索引。
|
||||||
|
- [候选手机号属于敏感信息] → 该能力仅超级管理员和平台用户可用,沿用现有平台业务员候选的手机号展示策略;实现时固定响应口径并避免审计复制完整手机号。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 不执行数据库迁移;发布成员/候选 Query、增量写用例和新路由。
|
||||||
|
2. 同步前端:PUT 增加改为 POST,全局 DELETE 改为携带组 ID 的 DELETE;接入成员和候选分页。
|
||||||
|
3. 在维护者指定测试环境验证新增、迁移、幂等、停用组移除、删除账号清理、跨组拒绝和并发裁决。
|
||||||
|
4. 回滚需同步恢复旧前端调用和旧二进制;成员关系本身仍兼容旧模型,不需要数据回滚。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
业务用户组当前只能看到组本身,无法分页查看成员或在选择用户时识别其当前所属组;现有全局清空入口还可能误清除其他组成员,导致前端把增量归属误解为整体替换。需要把成员查看、候选选择和增量增删收敛到明确的组作用域。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 新增组内成员分页列表,返回当前组的启用、禁用及已删除平台账号成员和关系状态。
|
||||||
|
- 新增成员候选分页列表,覆盖全部启用平台用户并返回当前所属组及 `add`、`already_member`、`move` 操作提示。
|
||||||
|
- 新增组作用域的增量增加接口:未分组用户加入目标组,其他组用户直接迁入,当前组成员幂等保持;不影响请求外成员。
|
||||||
|
- 新增组作用域的增量移除接口:只允许移除当前确实属于目标组的选中成员,支持清理禁用或已删除账号关系,不得清除其他组归属。
|
||||||
|
- **BREAKING**:以 `POST /api/admin/business-user-groups/{id}/members` 替换现有 `PUT` 成员设置入口,并移除全局 `DELETE /api/admin/business-user-groups/members`;调用方必须迁移到组作用域增量接口。
|
||||||
|
- 用户组列表与详情返回实时成员数量;成员变化继续实时影响店铺负责人所属组推导,不写店铺冗余字段。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `business-user-group`: 增加成员与候选查询、组作用域增量增删、成员计数及旧成员接口清理行为。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- 影响业务用户组 Query、DTO、Application、Store、Handler、路由、OpenAPI、审计和前端调用契约。
|
||||||
|
- 复用现有成员关系表、账号行锁、唯一约束和逐账号审计;不新增数据库迁移或第三方依赖。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 业务用户组成员与候选用户可分页查询
|
||||||
|
系统 SHALL 提供 `GET /api/admin/business-user-groups/{id}/members`,使超级管理员和平台用户分页查看指定业务用户组当前保留的全部成员关系。成员列表 MUST 支持用户名或手机号关键字和账号启用状态筛选,并返回账号标识、用户名、手机号、启用状态、已删除标记、成员关系创建时间以及当前组标识、编码、名称和启停状态。平台账号禁用或软删除后,其成员关系 SHALL 保留并继续显示;软删除账号的查询不得恢复账号或改变店铺推导。
|
||||||
|
|
||||||
|
系统 SHALL 提供 `GET /api/admin/business-user-groups/{id}/member-candidates`,分页返回全部启用且未删除的平台用户及其当前业务用户组。候选列表 MUST 支持用户名或手机号关键字,以及全部、未分组、当前组、其他组四类归属筛选;每项 MUST 返回当前组标识、编码、名称、启停状态和稳定操作提示:未分组为 `add`、已在目标组为 `already_member`、属于其他组为 `move`。目标组停用时仍可查询成员与候选,但候选 MUST 标记不可添加。
|
||||||
|
|
||||||
|
业务用户组列表与详情 SHALL 返回当前保留成员关系的实时数量。查询与计数 MUST 批量完成,列表查询次数不得随当页用户组或成员数量线性增长。上述入口仅限超级管理员和平台用户,代理与企业账号 MUST 被拒绝。
|
||||||
|
|
||||||
|
#### Scenario: 查看启用、禁用和已删除成员
|
||||||
|
- **GIVEN** 某用户组包含启用账号、禁用账号和已软删除账号的保留成员关系
|
||||||
|
- **WHEN** 授权账号查询组内成员
|
||||||
|
- **THEN** 系统返回三类成员并分别标记账号状态和已删除状态,不修改成员关系
|
||||||
|
|
||||||
|
#### Scenario: 候选列表展示当前所属组
|
||||||
|
- **GIVEN** 候选平台用户中分别存在未分组、当前组和其他组成员
|
||||||
|
- **WHEN** 授权账号查询目标组成员候选
|
||||||
|
- **THEN** 系统分别返回 `add`、`already_member`、`move` 提示及当前所属组信息
|
||||||
|
|
||||||
|
#### Scenario: 停用组仍可维护退出
|
||||||
|
- **WHEN** 授权账号查询已停用用户组的成员和候选
|
||||||
|
- **THEN** 系统仍返回查询结果,候选标记不可添加,但已有成员可通过组作用域移除接口退出该组
|
||||||
|
|
||||||
|
#### Scenario: 用户组列表返回实时成员数
|
||||||
|
- **WHEN** 成员迁入或移出用户组后查询用户组列表或详情
|
||||||
|
- **THEN** 系统立即返回新的成员数量,无需更新用户组或店铺记录
|
||||||
|
|
||||||
|
### Requirement: 业务用户组成员按组作用域增量增加与移除
|
||||||
|
系统 SHALL 提供 `POST /api/admin/business-user-groups/{id}/members` 增量增加成员。目标组 MUST 存在、未删除且启用;请求账号去重后 MUST 非空,且全部账号必须为启用、未删除的平台用户,任一账号无效时整批不修改。未分组账号 SHALL 新增归属,其他组账号 SHALL 直接迁入目标组,已在目标组账号 SHALL 幂等保持;请求外的目标组成员 MUST 不受影响。响应 MUST 分别返回请求数、新增数、迁移数和未变化数。
|
||||||
|
|
||||||
|
系统 SHALL 提供 `DELETE /api/admin/business-user-groups/{id}/members` 增量移除成员。目标组 MUST 存在且未删除,启用或停用均可操作;所有请求账号必须在操作开始时属于目标组,任一账号无归属或属于其他组时整批拒绝。移除 MUST 仅软删除目标组内对应成员关系,不得清除其他组归属;禁用或已软删除平台账号的保留成员关系 MUST 可被移除。重复移除已不属于目标组的账号 MUST 返回状态冲突,不得静默成功。
|
||||||
|
|
||||||
|
增加和移除 MUST 在单一事务内按账号标识稳定顺序串行处理,并为每个实际变化账号记录原组、目标组、操作者和时间审计;已在目标组的幂等账号不得写虚假变更审计。成员变化后,账号负责店铺的所属业务用户组和业务线 MUST 按既有实时推导立即变化,不得写入店铺冗余组字段。
|
||||||
|
|
||||||
|
#### Scenario: 增量增加不覆盖其他成员
|
||||||
|
- **GIVEN** 目标组已有多名成员
|
||||||
|
- **WHEN** 管理员提交一组新的账号加入目标组
|
||||||
|
- **THEN** 系统只处理请求账号,原有未被请求的成员保持不变
|
||||||
|
|
||||||
|
#### Scenario: 其他组成员直接迁入
|
||||||
|
- **WHEN** 管理员把属于另一启用或停用组的平台用户增加到启用目标组
|
||||||
|
- **THEN** 系统将该账号唯一归属迁入目标组,原组成员数减少、目标组成员数增加,并记录前后组审计
|
||||||
|
|
||||||
|
#### Scenario: 当前组成员重复增加
|
||||||
|
- **WHEN** 请求包含已经属于目标组的账号
|
||||||
|
- **THEN** 系统保持其唯一成员关系并计入未变化数,不新增重复关系或虚假变更审计
|
||||||
|
|
||||||
|
#### Scenario: 增加请求含无效账号整批回滚
|
||||||
|
- **WHEN** 增加请求中任一账号不是启用且未删除的平台用户
|
||||||
|
- **THEN** 系统拒绝整批请求,其他有效账号的归属也不变化
|
||||||
|
|
||||||
|
#### Scenario: 只移除当前组成员
|
||||||
|
- **WHEN** 管理员从目标组移除一组当前成员
|
||||||
|
- **THEN** 系统只软删除这些账号在目标组的关系,目标组其他成员和其他组关系不受影响
|
||||||
|
|
||||||
|
#### Scenario: 移除请求含其他组成员整批拒绝
|
||||||
|
- **WHEN** 移除请求中任一账号不属于目标组或已无分组
|
||||||
|
- **THEN** 系统返回状态冲突且不移除任何请求账号
|
||||||
|
|
||||||
|
#### Scenario: 清理禁用或已删除账号关系
|
||||||
|
- **WHEN** 管理员从目标组移除已禁用或已软删除平台账号的保留成员关系
|
||||||
|
- **THEN** 系统允许移除并保留账号本身的禁用或删除状态不变
|
||||||
|
|
||||||
|
### Requirement: 业务用户组成员接口干净切换
|
||||||
|
系统 MUST 以 `POST /api/admin/business-user-groups/{id}/members` 取代原 `PUT` 成员设置入口,并 MUST 移除全局 `DELETE /api/admin/business-user-groups/members`。系统 MUST NOT 保留旧方法或路径别名;调用方必须使用组作用域的增加与移除接口,避免无目标组约束的清空操作。
|
||||||
|
|
||||||
|
#### Scenario: 旧成员维护入口不可达
|
||||||
|
- **WHEN** 调用方请求原 `PUT /api/admin/business-user-groups/{id}/members` 或全局 `DELETE /api/admin/business-user-groups/members`
|
||||||
|
- **THEN** 路由不提供旧成员维护能力,调用方必须迁移到新的 POST 或组作用域 DELETE 入口
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## 1. 成员与候选查询
|
||||||
|
|
||||||
|
- [x] 1.1 新增成员列表、候选列表、分页筛选与响应 DTO,包含账号状态、删除标记、关系时间、当前组信息、操作提示和可添加标记。
|
||||||
|
- [x] 1.2 在业务用户组 Query 中实现当前成员分页:以成员关系为主表,`Unscoped` 批量读取启用、禁用和软删除平台账号,支持用户名/手机号与账号状态筛选。
|
||||||
|
- [x] 1.3 实现成员候选分页:仅启用未删除平台用户,批量关联当前成员关系和用户组,支持全部、未分组、当前组、其他组筛选并计算 `add`、`already_member`、`move`。
|
||||||
|
- [x] 1.4 为用户组列表和详情批量投影实时成员数,计数包含禁用或已删除账号的未删除成员关系,避免逐组查询。
|
||||||
|
|
||||||
|
## 2. 增量增加成员
|
||||||
|
|
||||||
|
- [x] 2.1 将成员增加请求与结果 DTO 调整为增量语义,响应返回请求数、新增数、迁移数、未变化数和账号集合。
|
||||||
|
- [x] 2.2 实现 `POST /business-user-groups/{id}/members` 用例:要求目标组启用且全部账号为启用未删除平台用户,按账号 ID 升序锁定并读取锁后归属。
|
||||||
|
- [x] 2.3 未分组账号新增关系、其他组账号更新到目标组、当前组账号幂等不写;请求外成员不修改,任一无效账号整批回滚。
|
||||||
|
- [x] 2.4 只为实际新增和迁移账号记录逐账号审计,审计包含原组与目标组;幂等未变化账号不写虚假变更事件。
|
||||||
|
|
||||||
|
## 3. 增量移除成员
|
||||||
|
|
||||||
|
- [x] 3.1 新增组作用域移除请求与结果 DTO,允许目标组启用或停用,并支持禁用或软删除账号关系清理。
|
||||||
|
- [x] 3.2 实现 `DELETE /business-user-groups/{id}/members` 用例:按账号 ID 升序锁账号,要求全部请求账号锁后仍属于目标组,任一不匹配整批返回状态冲突。
|
||||||
|
- [x] 3.3 Store 新增带目标组和账号集合谓词的软删除操作,要求影响行数等于请求账号数,禁止清除其他组归属。
|
||||||
|
- [x] 3.4 为每个实际移除账号记录原组、空目标组与操作者审计,账号禁用或删除状态保持不变。
|
||||||
|
|
||||||
|
## 4. 路由干净切换与装配
|
||||||
|
|
||||||
|
- [x] 4.1 新增 `GET /business-user-groups/{id}/members` 与 `GET /business-user-groups/{id}/member-candidates` Handler 和 RouteSpec,仅放行超级管理员与平台用户。
|
||||||
|
- [x] 4.2 将现有 `PUT /business-user-groups/{id}/members` 改为 POST,并新增同路径 DELETE;删除全局 `DELETE /business-user-groups/members`、旧 Handler 方法及专用 DTO,不保留别名。
|
||||||
|
- [x] 4.3 同步 `internal/bootstrap`、`cmd/api/docs.go`、`cmd/gendocs/main.go` 与 OpenAPI 描述,明确停用组只可移除、其他组成员增加时直接迁入。
|
||||||
|
|
||||||
|
## 5. 验证与规格同步
|
||||||
|
|
||||||
|
- [ ] 5.1 在维护者指定测试环境验证成员分页、关键字、启停与软删除展示,候选归属筛选和三类操作提示。(阻塞:未提供维护者指定测试环境、账号和数据集)
|
||||||
|
- [ ] 5.2 验证未分组新增、跨启用/停用组迁移、当前组幂等、无效账号整批回滚及请求外成员不变。(阻塞:未提供维护者指定测试环境、账号和数据集)
|
||||||
|
- [ ] 5.3 验证启用/停用组移除、禁用/删除账号关系清理、其他组或无归属账号导致整批拒绝、重复移除冲突。(阻塞:未提供维护者指定测试环境、账号和数据集)
|
||||||
|
- [ ] 5.4 验证并发迁移与移除同一账号的锁后裁决、逐账号审计、成员数实时变化和店铺所属组实时推导。(阻塞:未提供维护者指定测试环境、账号和数据集)
|
||||||
|
- [x] 5.5 验证旧 PUT 与全局 DELETE 路由不可达,新 GET/POST/DELETE 路由和生成文档一致;运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。
|
||||||
|
- [x] 5.6 同步主 Spec 可达操作索引和证据矩阵,运行 `openspec validate add-business-user-group-member-management --strict`、`openspec doctor --json`、`openspec validate --all` 与 `./scripts/context-health.sh`。
|
||||||
@@ -72,6 +72,48 @@
|
|||||||
- **WHEN** 某行操作类型为换绑但业务员登录账号为空,或操作类型为清空但填写了业务员登录账号
|
- **WHEN** 某行操作类型为换绑但业务员登录账号为空,或操作类型为清空但填写了业务员登录账号
|
||||||
- **THEN** 该行失败并保留店铺原负责人,其他行照常处理
|
- **THEN** 该行失败并保留店铺原负责人,其他行照常处理
|
||||||
|
|
||||||
|
### Requirement: 业务用户组成员与候选用户可分页查询
|
||||||
|
系统 SHALL 提供组内成员和成员候选分页查询。成员查询保留并展示启用、禁用及已软删除账号的未删除关系,支持用户名/手机号及账号状态筛选;候选查询仅返回启用且未删除的平台用户,支持全部、未分组、当前组和其他组筛选,并返回 `add`、`already_member`、`move` 操作提示。停用组仍可查询,候选统一标记不可添加;用户组列表和详情返回批量投影的实时保留成员数。
|
||||||
|
#### Scenario: 成员查询保留历史账号关系
|
||||||
|
- **WHEN** 授权账号查询包含启用、禁用和已删除账号关系的用户组
|
||||||
|
- **THEN** 系统分页返回三类成员并标记账号状态与删除状态,不修改关系
|
||||||
|
|
||||||
|
#### Scenario: 候选归属提示
|
||||||
|
- **WHEN** 授权账号查询目标组候选并分别存在未分组、当前组和其他组平台用户
|
||||||
|
- **THEN** 系统返回 `add`、`already_member`、`move` 提示及当前组信息
|
||||||
|
|
||||||
|
#### Scenario: 用户组成员数实时变化
|
||||||
|
- **WHEN** 成员迁入或移出后查询用户组列表或详情
|
||||||
|
- **THEN** 系统返回新的保留成员关系数量,无需更新组或店铺记录
|
||||||
|
|
||||||
|
#### Scenario: 增量增加与迁移
|
||||||
|
- **WHEN** 管理员向启用目标组提交启用未删除平台用户
|
||||||
|
- **THEN** 未分组账号新增、其他组账号迁入、当前组账号保持不变,任一无效账号整批回滚
|
||||||
|
|
||||||
|
#### Scenario: 组作用域增量移除
|
||||||
|
- **WHEN** 管理员从启用或停用目标组移除当前成员
|
||||||
|
- **THEN** 系统仅软删除目标组关系并记录逐账号审计,任一不匹配账号整批冲突
|
||||||
|
|
||||||
|
#### Scenario: 旧成员入口不可达
|
||||||
|
- **WHEN** 调用方请求旧 PUT 成员入口或全局 DELETE 成员入口
|
||||||
|
- **THEN** 系统不提供旧成员维护能力,调用方必须使用组作用域 POST/DELETE 入口
|
||||||
|
|
||||||
|
### Requirement: 业务用户组成员按组作用域增量增加与移除
|
||||||
|
系统 SHALL 以 `POST /api/admin/business-user-groups/{id}/members` 增量增加成员:目标组必须启用,账号必须是启用且未删除的平台用户;未分组账号新增、其他组账号迁入、当前组账号幂等,任一无效账号整批回滚。系统 SHALL 以 `DELETE /api/admin/business-user-groups/{id}/members` 增量移除成员:目标组启用或停用均可操作,所有账号必须锁后仍属于目标组;仅软删除目标组关系,禁用或已删除账号关系可清理,任一不匹配整批冲突。成员变化须逐账号记录原组、目标组、操作者和时间审计,并继续实时推导店铺所属组。
|
||||||
|
#### Scenario: 增量增加与迁移
|
||||||
|
- **WHEN** 管理员向启用目标组提交启用未删除平台用户
|
||||||
|
- **THEN** 未分组账号新增、其他组账号迁入、当前组账号保持不变,任一无效账号整批回滚
|
||||||
|
|
||||||
|
#### Scenario: 组作用域增量移除
|
||||||
|
- **WHEN** 管理员从启用或停用目标组移除当前成员
|
||||||
|
- **THEN** 系统仅软删除目标组关系并记录逐账号审计,任一不匹配账号整批冲突
|
||||||
|
|
||||||
|
### Requirement: 业务用户组成员接口干净切换
|
||||||
|
系统 MUST 不再提供旧成员维护入口和全局成员清空入口,不保留兼容别名;调用方必须使用组作用域的 POST/DELETE 接口。
|
||||||
|
#### Scenario: 旧成员入口不可达
|
||||||
|
- **WHEN** 调用方请求旧 PUT 成员入口或全局 DELETE 成员入口
|
||||||
|
- **THEN** 系统不提供旧成员维护能力,调用方必须使用组作用域 POST/DELETE 入口
|
||||||
|
|
||||||
## 可达操作索引
|
## 可达操作索引
|
||||||
|
|
||||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||||
@@ -82,7 +124,7 @@
|
|||||||
|
|
||||||
### 业务用户组成员归属
|
### 业务用户组成员归属
|
||||||
|
|
||||||
`PUT /api/admin/business-user-groups/{id}/members`(批量设置平台用户业务用户组归属);`DELETE /api/admin/business-user-groups/members`(批量清空平台用户业务用户组归属)。
|
`GET /api/admin/business-user-groups/{id}/members`(分页查询目标组成员,包含禁用及已删除账号关系);`GET /api/admin/business-user-groups/{id}/member-candidates`(分页查询启用平台用户候选及当前归属);`POST /api/admin/business-user-groups/{id}/members`(增量增加成员,其他组成员直接迁入);`DELETE /api/admin/business-user-groups/{id}/members`(按目标组作用域增量移除成员,启用或停用组均可操作)。旧 PUT 成员入口与全局 DELETE 成员入口不再提供。
|
||||||
|
|
||||||
### 店铺负责人批量交接
|
### 店铺负责人批量交接
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user