feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s

- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。
- 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。
- 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。
- 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。
- 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。
- 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
This commit is contained in:
2026-09-14 16:51:44 +08:00
parent 957a235585
commit c7f9e005af
56 changed files with 4166 additions and 344 deletions

View File

@@ -119,6 +119,27 @@ func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB {
if request.Status != nil {
db = db.Where("status = ?", *request.Status)
}
// 店铺所属组由当前负责人实时推导,因此筛选一律用存在性子查询;
// 主查询保持先 Count 再 Find 的同一 SQL改用 JOIN 会放大计数行并引入列歧义。
if request.BusinessUserGroupID != nil {
db = db.Where(
"EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND m.business_user_group_id = ?)",
*request.BusinessUserGroupID)
}
if request.BusinessLine != nil {
db = db.Where(
"EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND EXISTS (SELECT 1 FROM tb_business_user_group g WHERE g.id = m.business_user_group_id AND g.deleted_at IS NULL AND g.business_line = ?))",
*request.BusinessLine)
}
if request.Ungrouped != nil {
// 未分组只包含无负责人与负责人无未删除成员关系两种情形;停用组的负责人仍属于已分组。
const ungroupedCondition = "(tb_shop.business_owner_account_id IS NULL OR NOT EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL))"
if *request.Ungrouped {
db = db.Where(ungroupedCondition)
} else {
db = db.Where("NOT " + ungroupedCondition)
}
}
return db
}
@@ -132,6 +153,11 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
if err != nil {
return nil, err
}
// 在既有 loadBusinessOwners 之上追加两次批量查询即完成组推导,避免 N+1。
groups, err := q.loadBusinessUserGroups(ctx, ownerIDs)
if err != nil {
return nil, err
}
responses := make([]*dto.ShopResponse, 0, len(shops))
for _, shop := range shops {
response := &dto.ShopResponse{
@@ -151,7 +177,16 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
if owner, exists := owners[*shop.BusinessOwnerAccountID]; exists {
response.BusinessOwnerUsername = owner.Username
response.BusinessOwnerPhoneSummary = maskPhone(owner.Phone)
response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid
response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform &&
owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid
}
if group, exists := groups[*shop.BusinessOwnerAccountID]; exists {
groupID := group.ID
response.BusinessUserGroupID = &groupID
response.BusinessUserGroupCode = group.Code
response.BusinessUserGroupName = group.Name
response.BusinessUserGroupEnabled = group.Status == constants.StatusEnabled
response.BusinessUserGroupBusinessLine = group.BusinessLine
}
}
responses = append(responses, response)
@@ -204,6 +239,43 @@ func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint)
return result, nil
}
// loadBusinessUserGroups 按负责人账号批量推导当前所属业务用户组。
// 一账号至多一条未删除成员关系,因此先在成员上按 account_id 定位、再按组 ID 回表,
// 停用组与负责人为已软删账号的成员关系都照常返回,保证展示与筛选口径一致。
func (q *BusinessOwnerQuery) loadBusinessUserGroups(ctx context.Context, accountIDs []uint) (map[uint]model.BusinessUserGroup, error) {
result := make(map[uint]model.BusinessUserGroup, len(accountIDs))
if len(accountIDs) == 0 {
return result, nil
}
var members []model.BusinessUserGroupMember
if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).
Where("account_id IN ?", accountIDs).Find(&members).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询负责人业务用户组归属失败")
}
if len(members) == 0 {
return result, nil
}
groupIDs := make(map[uint]struct{}, len(members))
for _, member := range members {
groupIDs[member.BusinessUserGroupID] = struct{}{}
}
var groups []model.BusinessUserGroup
if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).
Where("id IN ?", mapKeys(groupIDs)).Find(&groups).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
}
groupByID := make(map[uint]model.BusinessUserGroup, len(groups))
for _, group := range groups {
groupByID[group.ID] = group
}
for _, member := range members {
if group, exists := groupByID[member.BusinessUserGroupID]; exists {
result[member.AccountID] = group
}
}
return result, nil
}
func mapKeys(values map[uint]struct{}) []uint {
keys := make([]uint, 0, len(values))
for key := range values {