// Package businessusergroup 提供业务用户组及其成员规模的只读投影。 // Query 只做筛选、分页与 DTO 投影,不修改任何状态。 package businessusergroup import ( "context" "strings" "time" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/sanitizer" ) // Query 查询业务用户组及其成员。 type Query struct { db *gorm.DB store *postgres.BusinessUserGroupStore } // NewQuery 创建业务用户组查询。 func NewQuery(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore) *Query { return &Query{db: db, store: groupStore} } // List 分页查询业务用户组,供维护与筛选下拉使用。 func (q *Query) List(ctx context.Context, request dto.BusinessUserGroupListRequest) (*dto.BusinessUserGroupPageResult, error) { if q == nil || q.db == nil { return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组查询尚未配置") } page, pageSize := normalizePage(request.Page, request.PageSize) status := enabledToStatus(request.Enabled) query := q.db.WithContext(ctx).Model(&model.BusinessUserGroup{}) if status != nil { query = query.Where("status = ?", *status) } if request.BusinessLine != nil { query = query.Where("business_line = ?", *request.BusinessLine) } if keyword := strings.TrimSpace(request.Keyword); keyword != "" { like := "%" + keyword + "%" query = query.Where("name ILIKE ? OR code ILIKE ?", like, like) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组总数失败") } var groups []*model.BusinessUserGroup if err := query.Order("sort_order ASC, id ASC"). 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 { item := toResponse(group) item.MemberCount = counts[group.ID] items = append(items, item) } return &dto.BusinessUserGroupPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil } // Detail 查询单个业务用户组详情。 func (q *Query) Detail(ctx context.Context, groupID uint) (*dto.BusinessUserGroupResponse, error) { if q == 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, "查询业务用户组失败") } 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 将业务用户组投影为对外响应。 func toResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse { return &dto.BusinessUserGroupResponse{ ID: group.ID, Code: group.Code, Name: group.Name, BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine), Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark, CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339), } } // enabledToStatus 把对外启停布尔映射为既有状态值;未提交时返回 nil 表示不过滤。 func enabledToStatus(enabled *bool) *int { if enabled == nil { return nil } status := constants.StatusDisabled if *enabled { status = constants.StatusEnabled } return &status } // normalizePage 归一化分页参数,执行 DefaultPageSize 与 MaxPageSize 上限。 func normalizePage(page, pageSize int) (int, int) { if page <= 0 { page = constants.DefaultPage } if pageSize <= 0 { pageSize = constants.DefaultPageSize } if pageSize > constants.MaxPageSize { pageSize = constants.MaxPageSize } return page, pageSize }