package user import ( "context" "fmt" "strings" "hyapp/pkg/appcode" userdomain "hyapp/services/user-service/internal/domain/user" ) // BatchGetUserAdminProfiles 聚合 user-service 同库内的用户、登录、角色和封禁事实。 // 该查询不读取 wallet/activity 等 owner 数据;admin-server 会分别批量读取并组装最终 DTO。 func (r *Repository) BatchGetUserAdminProfiles(ctx context.Context, userIDs []int64, _ int64) (map[int64]userdomain.AdminProfile, error) { users, err := r.BatchGetUsers(ctx, userIDs) if err != nil { return nil, err } profiles := make(map[int64]userdomain.AdminProfile, len(users)) for userID, item := range users { profiles[userID] = userdomain.AdminProfile{ User: item, BirthDate: item.BirthDate, RegisterDevice: item.RegisterDevice, Roles: make([]string, 0, 6), } } if len(profiles) == 0 { return profiles, nil } placeholders, idArgs := adminProfileIDArgs(userIDs) if err := r.hydrateAdminLastSuccessLogin(ctx, profiles, placeholders, idArgs); err != nil { return nil, err } if err := r.hydrateAdminRoles(ctx, profiles, placeholders, idArgs); err != nil { return nil, err } if err := r.hydrateAdminLastOperations(ctx, profiles, placeholders, idArgs); err != nil { return nil, err } if err := r.hydrateAdminBanSummaries(ctx, profiles, placeholders, idArgs); err != nil { return nil, err } return profiles, nil } func (r *Repository) hydrateAdminLastSuccessLogin(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error { args := append([]any{appcode.FromContext(ctx)}, idArgs...) rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT user_id, MAX(created_at_ms) FROM login_audit WHERE app_code = ? AND result = 'success' AND user_id IN (%s) GROUP BY user_id`, placeholders), args...) if err != nil { return err } defer rows.Close() for rows.Next() { var userID, loggedInAtMs int64 if err := rows.Scan(&userID, &loggedInAtMs); err != nil { return err } profile, ok := profiles[userID] if ok { profile.LastSuccessLoginAtMs = loggedInAtMs profiles[userID] = profile } } return rows.Err() } func (r *Repository) hydrateAdminRoles(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error { args := append([]any{appcode.FromContext(ctx)}, idArgs...) rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT u.user_id, EXISTS(SELECT 1 FROM host_profiles h WHERE h.app_code = u.app_code AND h.user_id = u.user_id AND h.status = 'active'), EXISTS(SELECT 1 FROM agencies a WHERE a.app_code = u.app_code AND a.active_owner_user_id = u.user_id), EXISTS(SELECT 1 FROM bd_profiles b WHERE b.app_code = u.app_code AND b.user_id = u.user_id AND b.status = 'active'), EXISTS(SELECT 1 FROM bd_leader_profiles bl WHERE bl.app_code = u.app_code AND bl.user_id = u.user_id AND bl.status = 'active'), EXISTS(SELECT 1 FROM coin_seller_profiles c WHERE c.app_code = u.app_code AND c.user_id = u.user_id AND c.status = 'active'), EXISTS(SELECT 1 FROM manager_profiles m WHERE m.app_code = u.app_code AND m.user_id = u.user_id AND m.status = 'active') FROM users u WHERE u.app_code = ? AND u.user_id IN (%s)`, placeholders), args...) if err != nil { return err } defer rows.Close() for rows.Next() { var userID int64 var host, agency, bd, bdLeader, coinSeller, manager bool if err := rows.Scan(&userID, &host, &agency, &bd, &bdLeader, &coinSeller, &manager); err != nil { return err } profile, ok := profiles[userID] if !ok { continue } // 固定顺序让 CSV、列表和详情在相同事实下始终产生一致展示,避免 map/set 顺序漂移。 profile.Roles = appendAdminRole(profile.Roles, host, userdomain.AdminRoleHost) profile.Roles = appendAdminRole(profile.Roles, agency, userdomain.AdminRoleAgency) profile.Roles = appendAdminRole(profile.Roles, bd, userdomain.AdminRoleBD) profile.Roles = appendAdminRole(profile.Roles, bdLeader, userdomain.AdminRoleBDLeader) profile.Roles = appendAdminRole(profile.Roles, coinSeller, userdomain.AdminRoleCoinSeller) profile.Roles = appendAdminRole(profile.Roles, manager, userdomain.AdminRoleManager) profiles[userID] = profile } return rows.Err() } func (r *Repository) hydrateAdminLastOperations(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error { args := make([]any, 0, len(idArgs)+2) args = append(args, appcode.FromContext(ctx)) args = append(args, idArgs...) args = append(args, appcode.FromContext(ctx)) rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT log.target_user_id, log.operator_type, log.operator_user_id, log.reason, log.created_at_ms FROM user_status_change_logs log INNER JOIN ( SELECT target_user_id, MAX(id) AS latest_id FROM user_status_change_logs WHERE app_code = ? AND target_user_id IN (%s) GROUP BY target_user_id ) latest ON latest.latest_id = log.id WHERE log.app_code = ?`, placeholders), args...) if err != nil { return err } defer rows.Close() for rows.Next() { var userID, operatorUserID, operatedAtMs int64 var operatorType, reason string if err := rows.Scan(&userID, &operatorType, &operatorUserID, &reason, &operatedAtMs); err != nil { return err } profile, ok := profiles[userID] if ok { profile.LastOperatorType = operatorType profile.LastOperatorUserID = operatorUserID profile.LastOperationReason = reason profile.LastOperationAtMs = operatedAtMs profiles[userID] = profile } } return rows.Err() } func (r *Repository) hydrateAdminBanSummaries(ctx context.Context, profiles map[int64]userdomain.AdminProfile, placeholders string, idArgs []any) error { args := make([]any, 0, len(idArgs)*2+2) args = append(args, appcode.FromContext(ctx)) args = append(args, idArgs...) args = append(args, appcode.FromContext(ctx)) args = append(args, idArgs...) rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT 'admin', ban_id, target_user_id, expires_at_ms, operator_admin_id, reason, created_at_ms FROM admin_user_bans WHERE app_code = ? AND status = 'active' AND target_user_id IN (%s) UNION ALL SELECT 'manager', block_id, target_user_id, blocked_until_ms, manager_user_id, reason, created_at_ms FROM manager_user_blocks WHERE app_code = ? AND status = 'active' AND target_user_id IN (%s)`, placeholders, placeholders), args...) if err != nil { return err } defer rows.Close() for rows.Next() { var source, banID, reason string var targetUserID, expiresAtMs, operatorUserID, createdAtMs int64 if err := rows.Scan(&source, &banID, &targetUserID, &expiresAtMs, &operatorUserID, &reason, &createdAtMs); err != nil { return err } profile, ok := profiles[targetUserID] if !ok { continue } profile.Ban = mergeActiveBan(profile.Ban, userdomain.ActiveBanSummary{ Active: true, Source: source, BanID: banID, Permanent: expiresAtMs == 0, ExpiresAtMs: expiresAtMs, UserStatus: profile.User.Status, OperatorType: source, OperatorUserID: operatorUserID, Reason: reason, CreatedAtMs: createdAtMs, ActiveBanCount: 1, }) profiles[targetUserID] = profile } if err := rows.Err(); err != nil { return err } for userID, profile := range profiles { if profile.Ban.Active || (profile.User.Status != userdomain.StatusBanned && profile.User.Status != userdomain.StatusDisabled) { continue } // 历史 banned/disabled 没有新事实表记录时一律视为永久;cron 绝不能仅凭时间猜测并恢复这些账号。 profile.Ban = userdomain.ActiveBanSummary{ Active: true, Source: userdomain.BanSourceLegacy, Permanent: true, UserStatus: profile.User.Status, OperatorType: profile.LastOperatorType, OperatorUserID: profile.LastOperatorUserID, Reason: profile.LastOperationReason, CreatedAtMs: profile.LastOperationAtMs, ActiveBanCount: 1, } profiles[userID] = profile } return nil } func adminProfileIDArgs(userIDs []int64) (string, []any) { placeholders := make([]string, 0, len(userIDs)) args := make([]any, 0, len(userIDs)) for _, userID := range userIDs { placeholders = append(placeholders, "?") args = append(args, userID) } return strings.Join(placeholders, ","), args } func appendAdminRole(roles []string, active bool, role string) []string { if active { return append(roles, role) } return roles } func mergeActiveBan(current userdomain.ActiveBanSummary, candidate userdomain.ActiveBanSummary) userdomain.ActiveBanSummary { if !current.Active { return candidate } current.ActiveBanCount += candidate.ActiveBanCount // 任一永久封禁都会让聚合视图永久;多个永久事实取最新一条作为可操作 ban_id 和审计展示。 if candidate.Permanent && (!current.Permanent || candidate.CreatedAtMs > current.CreatedAtMs) { candidate.ActiveBanCount = current.ActiveBanCount return candidate } if current.Permanent { return current } // 全部是限时事实时,账号只有在最晚一条也到期后才能恢复,因此摘要展示最晚截止时间。 if candidate.ExpiresAtMs > current.ExpiresAtMs || (candidate.ExpiresAtMs == current.ExpiresAtMs && candidate.CreatedAtMs > current.CreatedAtMs) { candidate.ActiveBanCount = current.ActiveBanCount return candidate } return current }