package appuser import ( "context" "database/sql" "errors" "fmt" "log/slog" "sort" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/security" activityv1 "hyapp.local/api/proto/activity/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" ) var ( ErrInvalidArgument = errors.New("invalid argument") ErrNotFound = errors.New("not found") ) type Service struct { userClient userclient.Client userAdminClient userAdminProjectionClient activityClient activityclient.Client levelAdminClient levelAdminProjectionClient walletClient rechargeStatsClient jobStore appUserExportJobStore jobsConfig config.JobsConfig adminDB *sql.DB userDB *sql.DB walletDB *sql.DB } // rechargeStatsClient 只暴露用户后台需要的累计充值聚合;列表不能为展示字段回扫钱包流水。 type rechargeStatsClient interface { BatchGetUserRechargeStats(context.Context, *walletv1.BatchGetUserRechargeStatsRequest) (*walletv1.BatchGetUserRechargeStatsResponse, error) } // userAdminProjectionClient 保证生日、成功登录和封禁语义继续由 user-service 统一解释。 type userAdminProjectionClient interface { BatchGetUserAdminProfiles(context.Context, *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error) AdminBanUser(context.Context, *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) AdminUnbanUser(context.Context, *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) } // levelAdminProjectionClient 只允许后台读 overlay 和一次原子调整,游戏等级没有写入口。 type levelAdminProjectionClient interface { BatchGetUserLevelAdminProfiles(context.Context, *activityv1.BatchGetUserLevelAdminProfilesRequest) (*activityv1.BatchGetUserLevelAdminProfilesResponse, error) AdjustTemporaryUserLevels(context.Context, *activityv1.AdjustTemporaryUserLevelsRequest) (*activityv1.AdjustTemporaryUserLevelsResponse, error) } type ServiceOption func(*Service) // WithWalletClient 通过 wallet-service 读取它拥有的充值聚合,保留 walletDB 仅用于现有余额与 VIP 展示。 func WithWalletClient(client walletclient.Client) ServiceOption { return func(service *Service) { service.walletClient = client } } // WithExportJobs 把任务创建留在 admin 数据库,把分批导出交给统一 job runner 执行。 func WithExportJobs(store appUserExportJobStore, cfg config.Config) ServiceOption { return func(service *Service) { service.jobStore = store service.jobsConfig = cfg.Jobs } } type AppUser struct { Age *int32 `json:"age,omitempty"` Avatar string `json:"avatar"` Balances []AppUserAssetBalance `json:"balances,omitempty"` Ban AppUserBan `json:"ban"` Birth string `json:"birth"` Coin int64 `json:"coin"` Country string `json:"country"` CountryDisplayName string `json:"countryDisplayName"` CountryName string `json:"countryName"` CreatedAtMs int64 `json:"createdAtMs"` CumulativeRechargeUSDMinor int64 `json:"cumulativeRechargeUsdMinor"` DefaultDisplayUserID string `json:"defaultDisplayUserId"` Diamond int64 `json:"diamond"` DisplayUserID string `json:"displayUserId"` EquippedResources []AppUserResource `json:"equippedResources,omitempty"` Gender string `json:"gender"` LastActiveAtMs int64 `json:"lastActiveAtMs"` LastLoginAtMs int64 `json:"lastLoginAtMs"` LastOperatedAtMs int64 `json:"lastOperatedAtMs"` LastOperator *AppUserOperator `json:"lastOperator,omitempty"` Levels AppUserLevels `json:"levels"` PrettyDisplayUserID string `json:"prettyDisplayUserId"` PrettyID string `json:"prettyId"` RegisterDevice string `json:"registerDevice"` RegionID int64 `json:"regionId"` RegionName string `json:"regionName"` Resources []AppUserResource `json:"resources,omitempty"` Roles []string `json:"roles"` Status string `json:"status"` UpdatedAtMs int64 `json:"updatedAtMs"` UserID string `json:"userId"` Username string `json:"username"` VIP AppUserVIP `json:"vip"` } // AppUserLevels 固定三个成长轨道,前端无需按数组位置猜测富豪、游戏和魅力列。 type AppUserLevels struct { Wealth AppUserLevel `json:"wealth"` Game AppUserLevel `json:"game"` Charm AppUserLevel `json:"charm"` } // AppUserLevel 同时暴露真实成长事实和当前临时展示事实,详情页可明确标出到期时间。 type AppUserLevel struct { Track string `json:"track"` RealLevel int32 `json:"realLevel"` RealTotalValue int64 `json:"realTotalValue"` DisplayLevel int32 `json:"displayLevel"` DisplayValue int64 `json:"displayValue"` TemporaryLevelID string `json:"temporaryLevelId,omitempty"` TemporaryTargetLevel int32 `json:"temporaryTargetLevel,omitempty"` StartedAtMs int64 `json:"startedAtMs,omitempty"` ExpiresAtMs int64 `json:"expiresAtMs,omitempty"` } type AppUserBan struct { ID string `json:"id,omitempty"` Active bool `json:"active"` Permanent bool `json:"permanent"` ExpiresAtMs int64 `json:"expiresAtMs"` Source string `json:"source,omitempty"` Reason string `json:"reason,omitempty"` } type AppUserOperator struct { AdminID string `json:"adminId"` Name string `json:"name"` Action string `json:"action"` } type AppUserAssetBalance struct { AssetType string `json:"assetType"` AvailableAmount int64 `json:"availableAmount"` FrozenAmount int64 `json:"frozenAmount"` TotalAmount int64 `json:"totalAmount"` Version int64 `json:"version"` UpdatedAtMs int64 `json:"updatedAtMs"` } type AppUserVIP struct { Level int32 `json:"level"` Name string `json:"name"` Active bool `json:"active"` StartedAtMs int64 `json:"startedAtMs"` ExpiresAtMs int64 `json:"expiresAtMs"` UpdatedAtMs int64 `json:"updatedAtMs"` } type AppUserResource struct { EntitlementID string `json:"entitlementId"` ResourceID int64 `json:"resourceId"` ResourceCode string `json:"resourceCode"` ResourceType string `json:"resourceType"` Name string `json:"name"` Status string `json:"status"` Quantity int64 `json:"quantity"` RemainingQuantity int64 `json:"remainingQuantity"` EffectiveAtMs int64 `json:"effectiveAtMs"` ExpiresAtMs int64 `json:"expiresAtMs"` SourceGrantID string `json:"sourceGrantId"` AssetURL string `json:"assetUrl"` PreviewURL string `json:"previewUrl"` AnimationURL string `json:"animationUrl"` Equipped bool `json:"equipped"` CreatedAtMs int64 `json:"createdAtMs"` UpdatedAtMs int64 `json:"updatedAtMs"` } type SetUserStatusResult struct { User AppUser `json:"user"` RevokedSessionCount int64 `json:"revokedSessionCount"` AccessTokenRevoked bool `json:"accessTokenRevoked"` AccessTokenError string `json:"accessTokenRevokeError,omitempty"` IMKicked bool `json:"imKicked"` IMKickError string `json:"imKickError,omitempty"` RoomEvicted bool `json:"roomEvicted"` RoomID string `json:"roomId,omitempty"` RTCKicked bool `json:"rtcKicked"` RTCKickError string `json:"rtcKickError,omitempty"` RoomEvictError string `json:"roomEvictError,omitempty"` } func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, opts ...ServiceOption) *Service { service := &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB} if adminClient, ok := any(userClient).(userAdminProjectionClient); ok { service.userAdminClient = adminClient } if adminClient, ok := any(activityClient).(levelAdminProjectionClient); ok { service.levelAdminClient = adminClient } for _, opt := range opts { if opt != nil { opt(service) } } return service } func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) { if s.userDB == nil { return nil, 0, fmt.Errorf("user mysql is not configured") } query = normalizeListQuery(query) appCode := appctx.FromContext(ctx) nowMs := query.SnapshotAtMs if nowMs <= 0 { nowMs = nowMillis() } whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs) total, err := countRows(ctx, s.userDB, whereSQL, args...) if err != nil { return nil, 0, err } if query.SortBy == "coin" { // 金币余额来自 wallet 库,必须先补齐当前筛选结果的余额再排序分页,避免只按当前页局部重排。 items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args, nowMs) return items, total, err } rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT u.user_id, u.current_display_user_id, u.default_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.gender, ''), COALESCE(u.country, ''), COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''), COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''), CASE WHEN COALESCE(u.region_id, 0) = 0 THEN 0 WHEN EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN u.region_id ELSE 0 END, CASE WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN 'GLOBAL' ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '') END, u.status, u.created_at_ms, u.updated_at_ms, GREATEST( COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0), COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0) ) %s %s %s LIMIT ? OFFSET ? `, appUserPrettySelectColumns(), whereSQL, appUserOrderSQL(query)), append(appUserSelectArgs(nowMs, args), query.PageSize, offset(query.Page, query.PageSize))...) if err != nil { return nil, 0, err } defer rows.Close() items, userIDs, err := scanAppUserRows(rows, query.PageSize) if err != nil { return nil, 0, err } if err := s.fillBalances(ctx, items, userIDs); err != nil { return nil, 0, err } if err := s.fillVIPLevels(ctx, items, userIDs); err != nil { return nil, 0, err } if err := s.fillUserAdminProfiles(ctx, items, userIDs); err != nil { return nil, 0, err } if err := s.fillLevelAdminProfiles(ctx, items, userIDs); err != nil { return nil, 0, err } if err := s.fillRechargeStats(ctx, items, userIDs); err != nil { return nil, 0, err } if err := s.fillLatestOperators(ctx, items, userIDs); err != nil { return nil, 0, err } return items, total, nil } func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any, nowMs int64) ([]AppUser, error) { rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT u.user_id, u.current_display_user_id, u.default_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.gender, ''), COALESCE(u.country, ''), COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''), COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''), CASE WHEN COALESCE(u.region_id, 0) = 0 THEN 0 WHEN EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN u.region_id ELSE 0 END, CASE WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN 'GLOBAL' ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '') END, u.status, u.created_at_ms, u.updated_at_ms, GREATEST( COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0), COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0) ) %s %s `, appUserPrettySelectColumns(), whereSQL), appUserSelectArgs(nowMs, args)...) if err != nil { return nil, err } defer rows.Close() items, userIDs, err := scanAppUserRows(rows, query.PageSize) if err != nil { return nil, err } if err := s.fillBalances(ctx, items, userIDs); err != nil { return nil, err } sortAppUsersByCoin(items, query.SortDirection) pagedItems := paginateAppUsers(items, query.Page, query.PageSize) if err := s.fillVIPLevels(ctx, pagedItems, appUserNumericIDs(pagedItems)); err != nil { return nil, err } pagedUserIDs := appUserNumericIDs(pagedItems) if err := s.fillUserAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil { return nil, err } if err := s.fillLevelAdminProfiles(ctx, pagedItems, pagedUserIDs); err != nil { return nil, err } if err := s.fillRechargeStats(ctx, pagedItems, pagedUserIDs); err != nil { return nil, err } if err := s.fillLatestOperators(ctx, pagedItems, pagedUserIDs); err != nil { return nil, err } return pagedItems, nil } func scanAppUserRows(rows *sql.Rows, capacity int) ([]AppUser, []int64, error) { items := make([]AppUser, 0, capacity) userIDs := make([]int64, 0, capacity) for rows.Next() { var item AppUser var userID int64 if err := rows.Scan( &userID, &item.DisplayUserID, &item.DefaultDisplayUserID, &item.Username, &item.Avatar, &item.Gender, &item.Country, &item.CountryName, &item.CountryDisplayName, &item.RegionID, &item.RegionName, &item.Status, &item.CreatedAtMs, &item.UpdatedAtMs, &item.LastActiveAtMs, &item.PrettyDisplayUserID, &item.PrettyID, ); err != nil { return nil, nil, err } item.UserID = strconv.FormatInt(userID, 10) items = append(items, item) userIDs = append(userIDs, userID) } if err := rows.Err(); err != nil { return nil, nil, err } return items, userIDs, nil } func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) { if s.userDB == nil { return AppUser{}, fmt.Errorf("user mysql is not configured") } var item AppUser var scannedUserID int64 appCode := appctx.FromContext(ctx) nowMs := nowMillis() err := s.userDB.QueryRowContext(ctx, ` SELECT u.user_id, u.current_display_user_id, u.default_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(u.gender, ''), COALESCE(u.country, ''), COALESCE((SELECT c.country_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''), COALESCE((SELECT c.country_display_name FROM countries c WHERE c.app_code = u.app_code AND c.country_code = u.country LIMIT 1), ''), CASE WHEN COALESCE(u.region_id, 0) = 0 THEN 0 WHEN EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN u.region_id ELSE 0 END, CASE WHEN COALESCE(u.region_id, 0) = 0 OR NOT EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 ) THEN 'GLOBAL' ELSE COALESCE((SELECT rg.name FROM regions rg WHERE rg.app_code = u.app_code AND rg.region_id = u.region_id LIMIT 1), '') END, u.status, u.created_at_ms, u.updated_at_ms, GREATEST( COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0), COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0) ) `+appUserPrettySelectColumns()+` FROM users u WHERE u.app_code = ? AND u.user_id = ? `, nowMs, nowMs, appCode, userID).Scan( &scannedUserID, &item.DisplayUserID, &item.DefaultDisplayUserID, &item.Username, &item.Avatar, &item.Gender, &item.Country, &item.CountryName, &item.CountryDisplayName, &item.RegionID, &item.RegionName, &item.Status, &item.CreatedAtMs, &item.UpdatedAtMs, &item.LastActiveAtMs, &item.PrettyDisplayUserID, &item.PrettyID, ) if errors.Is(err, sql.ErrNoRows) { return AppUser{}, ErrNotFound } if err != nil { return AppUser{}, err } item.UserID = strconv.FormatInt(scannedUserID, 10) items := []AppUser{item} if err := s.fillBalances(ctx, items, []int64{userID}); err != nil { return AppUser{}, err } if err := s.fillVIPLevels(ctx, items, []int64{userID}); err != nil { return AppUser{}, err } if err := s.fillUserAdminProfiles(ctx, items, []int64{userID}); err != nil { return AppUser{}, err } if err := s.fillLevelAdminProfiles(ctx, items, []int64{userID}); err != nil { return AppUser{}, err } if err := s.fillRechargeStats(ctx, items, []int64{userID}); err != nil { return AppUser{}, err } if err := s.fillLatestOperators(ctx, items, []int64{userID}); err != nil { return AppUser{}, err } if err := s.fillUserResources(ctx, items, userID); err != nil { return AppUser{}, err } return items[0], nil } func (s *Service) fillUserAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error { initializeAdminProjectionDefaults(items) if s.userAdminClient == nil || len(items) == 0 || len(userIDs) == 0 { return nil } nowMs := nowMillis() response, err := s.userAdminClient.BatchGetUserAdminProfiles(ctx, &userv1.BatchGetUserAdminProfilesRequest{ Meta: &userv1.RequestMeta{ RequestId: fmt.Sprintf("admin-app-user-profile-%d", nowMs), Caller: "hyapp-admin-server", SentAtMs: nowMs, AppCode: appctx.FromContext(ctx), }, UserIds: userIDs, }) if err != nil { return err } index := appUserIndex(items) for userID, profile := range response.GetProfiles() { if profile == nil { continue } i, ok := index[userID] if !ok { continue } items[i].Birth = strings.TrimSpace(profile.GetBirth()) items[i].Age = ageAtUTC(items[i].Birth, time.UnixMilli(nowMs).UTC()) items[i].RegisterDevice = strings.TrimSpace(profile.GetRegisterDevice()) items[i].LastLoginAtMs = profile.GetLastSuccessLoginAtMs() items[i].Roles = displayUserRoles(profile.GetRoles()) if ban := profile.GetBan(); ban != nil { items[i].Ban = AppUserBan{ ID: strings.TrimSpace(ban.GetBanId()), Active: ban.GetActive(), Permanent: ban.GetPermanent(), ExpiresAtMs: ban.GetExpiresAtMs(), Source: strings.TrimSpace(ban.GetSource()), Reason: strings.TrimSpace(ban.GetReason()), } } if profile.GetLastOperationAtMs() > items[i].LastOperatedAtMs { items[i].LastOperatedAtMs = profile.GetLastOperationAtMs() items[i].LastOperator = &AppUserOperator{ AdminID: strconv.FormatInt(profile.GetLastOperatorUserId(), 10), Name: displayOperatorType(profile.GetLastOperatorType()), Action: strings.TrimSpace(profile.GetLastOperationReason()), } } } return nil } func (s *Service) fillLevelAdminProfiles(ctx context.Context, items []AppUser, userIDs []int64) error { initializeAdminProjectionDefaults(items) if s.levelAdminClient == nil || len(items) == 0 || len(userIDs) == 0 { return nil } nowMs := nowMillis() response, err := s.levelAdminClient.BatchGetUserLevelAdminProfiles(ctx, &activityv1.BatchGetUserLevelAdminProfilesRequest{ Meta: &activityv1.RequestMeta{ RequestId: fmt.Sprintf("admin-app-user-level-%d", nowMs), Caller: "hyapp-admin-server", SentAtMs: nowMs, AppCode: appctx.FromContext(ctx), }, UserIds: userIDs, }) if err != nil { return err } index := appUserIndex(items) for _, profile := range response.GetProfiles() { if profile == nil { continue } i, ok := index[profile.GetUserId()] if !ok { continue } for _, track := range profile.GetTracks() { if track == nil { continue } level := AppUserLevel{ Track: strings.ToLower(strings.TrimSpace(track.GetTrack())), RealLevel: track.GetRealLevel(), RealTotalValue: track.GetRealTotalValue(), DisplayLevel: track.GetDisplayLevel(), DisplayValue: track.GetDisplayValue(), TemporaryLevelID: strings.TrimSpace(track.GetTemporaryLevelId()), TemporaryTargetLevel: track.GetTemporaryTargetLevel(), StartedAtMs: track.GetStartedAtMs(), ExpiresAtMs: track.GetExpiresAtMs(), } switch level.Track { case "wealth": items[i].Levels.Wealth = level case "charm": items[i].Levels.Charm = level case "game": items[i].Levels.Game = level } } } return nil } func initializeAdminProjectionDefaults(items []AppUser) { for i := range items { if len(items[i].Roles) == 0 { items[i].Roles = []string{"普通用户"} } if items[i].Levels.Wealth.Track == "" { items[i].Levels.Wealth.Track = "wealth" } if items[i].Levels.Charm.Track == "" { items[i].Levels.Charm.Track = "charm" } if items[i].Levels.Game.Track == "" { items[i].Levels.Game.Track = "game" } } } func displayUserRoles(roles []string) []string { displayByRole := map[string]string{ "host": "主播", "agency": "公会长", "bd": "BD", "bd_leader": "BD Leader", "coin_seller": "币商", "manager": "经理", } seen := make(map[string]struct{}, len(roles)) for _, role := range roles { role = strings.ToLower(strings.TrimSpace(role)) if _, ok := displayByRole[role]; ok { seen[role] = struct{}{} } } order := []string{"host", "agency", "bd", "bd_leader", "coin_seller", "manager"} result := make([]string, 0, len(seen)) for _, role := range order { if _, ok := seen[role]; ok { result = append(result, displayByRole[role]) } } if len(result) == 0 { return []string{"普通用户"} } return result } func displayOperatorType(operatorType string) string { switch strings.ToLower(strings.TrimSpace(operatorType)) { case "admin": return "管理员" case "manager": return "经理" default: return strings.TrimSpace(operatorType) } } func ageAtUTC(birth string, now time.Time) *int32 { birthDate, err := time.Parse("2006-01-02", strings.TrimSpace(birth)) if err != nil || birthDate.After(now) { return nil } age := now.Year() - birthDate.Year() if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) { age-- } if age < 0 { return nil } value := int32(age) return &value } func (s *Service) fillRechargeStats(ctx context.Context, items []AppUser, userIDs []int64) error { if s.walletClient == nil || len(items) == 0 || len(userIDs) == 0 { return nil } response, err := s.walletClient.BatchGetUserRechargeStats(ctx, &walletv1.BatchGetUserRechargeStatsRequest{ RequestId: fmt.Sprintf("admin-app-user-recharge-%d", nowMillis()), AppCode: appctx.FromContext(ctx), UserIds: userIDs, }) if err != nil { return err } index := appUserIndex(items) for _, stat := range response.GetItems() { if stat == nil { continue } if i, ok := index[stat.GetUserId()]; ok { items[i].CumulativeRechargeUSDMinor = stat.GetTotalUsdMinorAmount() } } return nil } func (s *Service) fillLatestOperators(ctx context.Context, items []AppUser, userIDs []int64) error { if s.adminDB == nil || len(items) == 0 || len(userIDs) == 0 { return nil } args := make([]any, 0, len(userIDs)+1) args = append(args, "app_users") for _, userID := range userIDs { args = append(args, strconv.FormatInt(userID, 10)) } rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(` SELECT resource_id, user_id, username, action, created_at_ms FROM admin_operation_logs WHERE resource = ? AND resource_id IN (%s) AND status = 'success' ORDER BY resource_id ASC, created_at_ms DESC, id DESC `, placeholders(len(userIDs))), args...) if err != nil { return err } defer rows.Close() index := appUserIndex(items) seen := make(map[int64]struct{}, len(userIDs)) for rows.Next() { var resourceID string var adminID uint64 var name string var action string var operatedAtMs int64 if err := rows.Scan(&resourceID, &adminID, &name, &action, &operatedAtMs); err != nil { return err } userID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) if err != nil { continue } if _, ok := seen[userID]; ok { continue } i, ok := index[userID] if !ok { continue } seen[userID] = struct{}{} if operatedAtMs <= items[i].LastOperatedAtMs { continue } items[i].LastOperatedAtMs = operatedAtMs items[i].LastOperator = &AppUserOperator{ AdminID: strconv.FormatUint(adminID, 10), Name: strings.TrimSpace(name), Action: strings.TrimSpace(action), } } return rows.Err() } func appUserIndex(items []AppUser) map[int64]int { index := make(map[int64]int, len(items)) for i := range items { userID, err := strconv.ParseInt(strings.TrimSpace(items[i].UserID), 10, 64) if err == nil && userID > 0 { index[userID] = i } } return index } func (s *Service) UpdateUser(ctx context.Context, userID int64, adminUserID int64, requestID string, req updateUserRequest) (AppUser, error) { if s.userDB == nil { return AppUser{}, fmt.Errorf("user mysql is not configured") } sets := make([]string, 0, 4) args := make([]any, 0, 8) if req.Username != nil { value := strings.TrimSpace(*req.Username) if len(value) > 64 { return AppUser{}, fmt.Errorf("%w: 用户名称不能超过 64 个字符", ErrInvalidArgument) } sets = append(sets, "username = ?") args = append(args, nullableString(value)) } if req.Avatar != nil { value := strings.TrimSpace(*req.Avatar) if len(value) > 512 { return AppUser{}, fmt.Errorf("%w: 头像不能超过 512 个字符", ErrInvalidArgument) } sets = append(sets, "avatar = ?") args = append(args, nullableString(value)) } if req.Gender != nil { value := strings.ToLower(strings.TrimSpace(*req.Gender)) if len(value) > 32 { return AppUser{}, fmt.Errorf("%w: 性别不能超过 32 个字符", ErrInvalidArgument) } sets = append(sets, "gender = ?") args = append(args, nullableString(value)) } if len(sets) > 0 { sets = append(sets, "updated_at_ms = ?") appCode := appctx.FromContext(ctx) args = append(args, nowMillis()) args = append(args, appCode, userID) result, err := s.userDB.ExecContext(ctx, "UPDATE users SET "+strings.Join(sets, ", ")+" WHERE app_code = ? AND user_id = ?", args...) if err != nil { return AppUser{}, err } affected, err := result.RowsAffected() if err != nil { return AppUser{}, err } if affected == 0 { return AppUser{}, ErrNotFound } } if req.Country != nil { if s.userClient == nil { return AppUser{}, fmt.Errorf("user client is not configured") } change, err := s.userClient.AdminChangeUserCountry(ctx, userclient.AdminChangeUserCountryRequest{ RequestID: strings.TrimSpace(requestID), Caller: "hyapp-admin-server", TargetUserID: userID, Country: *req.Country, AdminUserID: adminUserID, Reason: "admin_user_country_changed", }) if err != nil { return AppUser{}, err } if change != nil && change.RegionChanged { s.removeOldRegionBroadcastMemberBestEffort(ctx, userID, change.OldRegionID, change.NewRegionID, strings.TrimSpace(requestID), "admin_user_country_changed") } } if len(sets) == 0 && req.Country == nil { return s.GetUser(ctx, userID) } return s.GetUser(ctx, userID) } func (s *Service) removeOldRegionBroadcastMemberBestEffort(ctx context.Context, userID int64, oldRegionID int64, newRegionID int64, requestID string, reason string) { if s == nil || s.activityClient == nil || userID <= 0 || oldRegionID <= 0 || oldRegionID == newRegionID { return } _, err := s.activityClient.RemoveRegionBroadcastMember(ctx, &activityv1.RemoveRegionBroadcastMemberRequest{ Meta: &activityv1.RequestMeta{ RequestId: strings.TrimSpace(requestID), Caller: "hyapp-admin-server", SentAtMs: nowMillis(), AppCode: appctx.FromContext(ctx), }, UserId: userID, RegionId: oldRegionID, Reason: reason, }) if err != nil { // 后台国家更新已经写入 users;IM 成员移除是外部副作用,失败不回滚资料,callback 会阻止用户再次加入旧区域群。 slog.WarnContext(ctx, "admin_im_region_group_member_remove_failed", slog.Int64("user_id", userID), slog.Int64("old_region_id", oldRegionID), slog.Int64("new_region_id", newRegionID), slog.String("request_id", requestID), slog.String("error", err.Error()), ) } } func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string, operatorUserID int64, requestID string) (SetUserStatusResult, error) { status = strings.ToLower(strings.TrimSpace(status)) if status != "active" && status != "banned" && status != "disabled" { return SetUserStatusResult{}, fmt.Errorf("%w: 状态不正确", ErrInvalidArgument) } if s.userClient == nil { return SetUserStatusResult{}, fmt.Errorf("user service client is not configured") } result, err := s.userClient.SetUserStatus(ctx, userclient.SetUserStatusRequest{ RequestID: strings.TrimSpace(requestID), Caller: "hyapp-admin-server", TargetUserID: userID, Status: status, OperatorType: "admin", OperatorUserID: operatorUserID, Reason: "admin_app_user_status", }) if err != nil { return SetUserStatusResult{}, err } user, err := s.GetUser(ctx, userID) if err != nil { return SetUserStatusResult{}, err } output := SetUserStatusResult{User: user} if result != nil { output.RevokedSessionCount = result.RevokedSessionCount output.AccessTokenRevoked = result.AccessTokenRevoked output.AccessTokenError = result.AccessTokenError output.IMKicked = result.IMKicked output.IMKickError = result.IMKickError output.RoomEvicted = result.RoomEvicted output.RoomID = result.RoomID output.RTCKicked = result.RTCKicked output.RTCKickError = result.RTCKickError output.RoomEvictError = result.RoomEvictError } return output, nil } func (s *Service) AdjustTemporaryLevels(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req adjustTemporaryLevelsRequest) (AppUser, error) { if s.levelAdminClient == nil { return AppUser{}, fmt.Errorf("activity service level admin client is not configured") } if userID <= 0 || operatorAdminID <= 0 || len(req.Adjustments) == 0 || len(req.Adjustments) > 2 { return AppUser{}, fmt.Errorf("%w: 等级调整参数不正确", ErrInvalidArgument) } seen := make(map[string]struct{}, len(req.Adjustments)) adjustments := make([]*activityv1.TemporaryLevelAdjustment, 0, len(req.Adjustments)) for _, adjustment := range req.Adjustments { track := strings.ToLower(strings.TrimSpace(adjustment.Track)) if track != "wealth" && track != "charm" { return AppUser{}, fmt.Errorf("%w: 只允许调整富豪或魅力等级", ErrInvalidArgument) } if _, duplicated := seen[track]; duplicated { return AppUser{}, fmt.Errorf("%w: 同一等级轨道不能重复提交", ErrInvalidArgument) } if adjustment.Level < 1 || adjustment.Level > 50 || adjustment.DurationDays <= 0 { return AppUser{}, fmt.Errorf("%w: 等级必须为 1-50 且有效期必须大于 0 天", ErrInvalidArgument) } seen[track] = struct{}{} adjustments = append(adjustments, &activityv1.TemporaryLevelAdjustment{ Track: track, Level: adjustment.Level, DurationDays: adjustment.DurationDays, }) } requestID = strings.TrimSpace(requestID) if requestID == "" { return AppUser{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument) } nowMs := nowMillis() reason := strings.TrimSpace(req.Reason) if reason == "" { reason = "admin_temporary_user_level" } _, err := s.levelAdminClient.AdjustTemporaryUserLevels(ctx, &activityv1.AdjustTemporaryUserLevelsRequest{ Meta: &activityv1.RequestMeta{ RequestId: requestID, Caller: "hyapp-admin-server", SentAtMs: nowMs, AppCode: appctx.FromContext(ctx), }, CommandId: fmt.Sprintf("app-user-level:%s:%d", requestID, userID), UserId: userID, Adjustments: adjustments, OperatorAdminId: operatorAdminID, Reason: reason, }) if err != nil { return AppUser{}, err } return s.GetUser(ctx, userID) } func (s *Service) AdminBanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req banUserRequest) (SetUserStatusResult, error) { if s.userAdminClient == nil { return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured") } nowMs := nowMillis() if userID <= 0 || operatorAdminID <= 0 || req.ExpiresAtMs < 0 || (req.ExpiresAtMs > 0 && req.ExpiresAtMs <= nowMs) { return SetUserStatusResult{}, fmt.Errorf("%w: 封禁截止时间必须为 0 或晚于当前 UTC 时间", ErrInvalidArgument) } requestID = strings.TrimSpace(requestID) if requestID == "" { return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument) } reason := strings.TrimSpace(req.Reason) if reason == "" { reason = "admin_app_user_ban" } response, err := s.userAdminClient.AdminBanUser(ctx, &userv1.AdminBanUserRequest{ Meta: &userv1.RequestMeta{ RequestId: requestID, Caller: "hyapp-admin-server", SentAtMs: nowMs, AppCode: appctx.FromContext(ctx), }, CommandId: fmt.Sprintf("app-user-ban:%s:%d", requestID, userID), TargetUserId: userID, ExpiresAtMs: req.ExpiresAtMs, OperatorAdminId: operatorAdminID, Reason: reason, }) if err != nil { return SetUserStatusResult{}, err } return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus()) } func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdminID int64, requestID string, req unbanUserRequest) (SetUserStatusResult, error) { if s.userAdminClient == nil { return SetUserStatusResult{}, fmt.Errorf("user service admin ban client is not configured") } if userID <= 0 || operatorAdminID <= 0 { return SetUserStatusResult{}, fmt.Errorf("%w: 解封参数不正确", ErrInvalidArgument) } requestID = strings.TrimSpace(requestID) if requestID == "" { return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument) } current, err := s.GetUser(ctx, userID) if err != nil { return SetUserStatusResult{}, err } // 历史 banned/disabled 用户没有独立 ban_id;这类永久兼容记录只能沿用旧状态接口手动恢复。 if strings.TrimSpace(current.Ban.ID) == "" { if current.Ban.Active && current.Ban.Source == "legacy" { return s.SetUserStatus(ctx, userID, "active", operatorAdminID, requestID) } } reason := strings.TrimSpace(req.Reason) if reason == "" { reason = "admin_app_user_unban" } nowMs := nowMillis() response, err := s.userAdminClient.AdminUnbanUser(ctx, &userv1.AdminUnbanUserRequest{ Meta: &userv1.RequestMeta{ RequestId: requestID, Caller: "hyapp-admin-server", SentAtMs: nowMs, AppCode: appctx.FromContext(ctx), }, // 摘要可能选中一条更晚到期的 manager 封禁;留空由 user-service 选择最新 admin 事实。 BanId: "", TargetUserId: userID, OperatorAdminId: operatorAdminID, Reason: reason, }) if err != nil { return SetUserStatusResult{}, err } return s.userStatusResultFromAdminResponse(ctx, userID, response.GetStatus()) } func (s *Service) userStatusResultFromAdminResponse(ctx context.Context, userID int64, status *userv1.SetUserStatusResponse) (SetUserStatusResult, error) { user, err := s.GetUser(ctx, userID) if err != nil { return SetUserStatusResult{}, err } result := SetUserStatusResult{User: user} if status == nil { return result, nil } result.RevokedSessionCount = status.GetRevokedSessionCount() result.AccessTokenRevoked = status.GetAccessTokenRevoked() result.AccessTokenError = status.GetAccessTokenRevokeError() result.IMKicked = status.GetImKicked() result.IMKickError = status.GetImKickError() result.RoomEvicted = status.GetRoomEvicted() result.RoomID = status.GetRoomId() result.RTCKicked = status.GetRtcKicked() result.RTCKickError = status.GetRtcKickError() result.RoomEvictError = status.GetRoomEvictError() return result, nil } func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error { if s.userDB == nil { return fmt.Errorf("user mysql is not configured") } password = strings.TrimSpace(password) if len(password) > 128 { return fmt.Errorf("%w: 密码不能超过 128 个字符", ErrInvalidArgument) } hash, err := security.HashPassword(password) if err != nil { return fmt.Errorf("%w: %v", ErrInvalidArgument, err) } var exists int appCode := appctx.FromContext(ctx) if err := s.userDB.QueryRowContext(ctx, `SELECT 1 FROM users WHERE app_code = ? AND user_id = ?`, appCode, userID).Scan(&exists); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } return err } now := nowMillis() _, err = s.userDB.ExecContext(ctx, ` INSERT INTO password_accounts (app_code, user_id, password_hash, hash_alg, created_at_ms, updated_at_ms) VALUES (?, ?, ?, 'bcrypt', ?, ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), hash_alg = VALUES(hash_alg), updated_at_ms = VALUES(updated_at_ms) `, appCode, userID, hash, now, now) return err } func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []int64) error { if s.walletDB == nil || len(userIDs) == 0 { return nil } index := make(map[int64]int, len(userIDs)) for i, id := range userIDs { index[id] = i } appCode := appctx.FromContext(ctx) const chunkSize = 500 for start := 0; start < len(userIDs); start += chunkSize { end := start + chunkSize if end > len(userIDs) { end = len(userIDs) } // 用户列表金币排序会对完整筛选集补余额,按块查询可以避免 IN 参数过长导致后台列表失败。 chunk := userIDs[start:end] args := make([]any, 0, len(chunk)+1) args = append(args, appCode) for _, id := range chunk { args = append(args, id) } rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(` SELECT user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms FROM wallet_accounts WHERE app_code = ? AND user_id IN (%s) ORDER BY user_id ASC, asset_type ASC `, placeholders(len(chunk))), args...) if err != nil { return err } for rows.Next() { var userID int64 var balance AppUserAssetBalance if err := rows.Scan( &userID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount, &balance.Version, &balance.UpdatedAtMs, ); err != nil { _ = rows.Close() return err } i, ok := index[userID] if !ok { continue } balance.AssetType = strings.ToUpper(strings.TrimSpace(balance.AssetType)) balance.TotalAmount = balance.AvailableAmount + balance.FrozenAmount // 钱包资产明细作为详情页“全部资产”的余额来源;金币和钻石仍同步到顶层字段,兼容列表排序和旧页面读取。 items[i].Balances = append(items[i].Balances, balance) switch balance.AssetType { case "COIN": items[i].Coin = balance.AvailableAmount case "DIAMOND": items[i].Diamond = balance.AvailableAmount } } if err := rows.Err(); err != nil { _ = rows.Close() return err } if err := rows.Close(); err != nil { return err } } return nil } func (s *Service) fillVIPLevels(ctx context.Context, items []AppUser, userIDs []int64) error { if s.walletDB == nil || len(userIDs) == 0 { return nil } index := make(map[int64]int, len(userIDs)) for i, id := range userIDs { index[id] = i } appCode := appctx.FromContext(ctx) nowMs := nowMillis() const chunkSize = 500 for start := 0; start < len(userIDs); start += chunkSize { end := start + chunkSize if end > len(userIDs) { end = len(userIDs) } chunk := userIDs[start:end] args := make([]any, 0, len(chunk)+1) args = append(args, appCode) for _, id := range chunk { args = append(args, id) } rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(` SELECT m.user_id, m.level, COALESCE(v.name, m.name), m.started_at_ms, m.expires_at_ms, m.updated_at_ms FROM user_vip_memberships m LEFT JOIN vip_levels v ON v.app_code = m.app_code AND v.level = m.level WHERE m.app_code = ? AND m.user_id IN (%s) `, placeholders(len(chunk))), args...) if err != nil { return err } for rows.Next() { var userID int64 var vip AppUserVIP if err := rows.Scan(&userID, &vip.Level, &vip.Name, &vip.StartedAtMs, &vip.ExpiresAtMs, &vip.UpdatedAtMs); err != nil { _ = rows.Close() return err } i, ok := index[userID] if !ok { continue } // 这里刻意沿用 wallet-service GetMyVip 的展示口径:只看 level 和 expires_at_ms 计算当前 VIP, // 过期会员仍保留历史行,但后台用户列表不展示为有效等级。 vip.Active = vip.Level > 0 && vip.ExpiresAtMs > nowMs if !vip.Active { vip.Level = 0 vip.Name = "" } items[i].VIP = vip } if err := rows.Err(); err != nil { _ = rows.Close() return err } if err := rows.Close(); err != nil { return err } } return nil } func (s *Service) fillUserResources(ctx context.Context, items []AppUser, userID int64) error { if s.walletDB == nil || len(items) == 0 || userID <= 0 { return nil } appCode := appctx.FromContext(ctx) nowMs := nowMillis() rows, err := s.walletDB.QueryContext(ctx, ` SELECT e.entitlement_id, e.resource_id, r.resource_code, r.resource_type, r.name, e.status, e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.source_grant_id, COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, ''), CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped, e.created_at_ms, e.updated_at_ms FROM user_resource_entitlements e JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code AND eq.user_id = e.user_id AND eq.resource_id = e.resource_id AND eq.entitlement_id = e.entitlement_id WHERE e.app_code = ? AND e.user_id = ? AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0 AND r.status = 'active' ORDER BY equipped DESC, r.resource_type ASC, e.updated_at_ms DESC, e.created_at_ms DESC `, appCode, userID, nowMs, nowMs) if err != nil { return err } defer rows.Close() resources := make([]AppUserResource, 0) equipped := make([]AppUserResource, 0) for rows.Next() { var resource AppUserResource if err := rows.Scan( &resource.EntitlementID, &resource.ResourceID, &resource.ResourceCode, &resource.ResourceType, &resource.Name, &resource.Status, &resource.Quantity, &resource.RemainingQuantity, &resource.EffectiveAtMs, &resource.ExpiresAtMs, &resource.SourceGrantID, &resource.AssetURL, &resource.PreviewURL, &resource.AnimationURL, &resource.Equipped, &resource.CreatedAtMs, &resource.UpdatedAtMs, ); err != nil { return err } resources = append(resources, resource) if resource.Equipped { // 装备状态只从 user_resource_equipment 读取;前端可以直接展示当前佩戴,不需要再按类型猜测。 equipped = append(equipped, resource) } } if err := rows.Err(); err != nil { return err } items[0].Resources = resources items[0].EquippedResources = equipped return nil } func normalizeListQuery(query listQuery) listQuery { if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } query.Country = normalizeCountryCode(query.Country) query.Keyword = strings.TrimSpace(query.Keyword) query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID) query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID) query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username) if query.RegionID < 0 { query.RegionID = 0 query.RegionIDSet = false } if query.StartMs < 0 { query.StartMs = 0 } if query.EndMs < 0 { query.EndMs = 0 } if query.SnapshotAtMs < 0 { query.SnapshotAtMs = 0 } query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.SortBy = normalizeAppUserSortBy(query.SortBy) query.SortDirection = normalizeSortDirection(query.SortDirection) return query } func appUserListWhereSQL(appCode string, query listQuery) (string, []any) { return appUserListWhereSQLAt(appCode, query, nowMillis()) } func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string, []any) { whereSQL := "FROM users u WHERE u.app_code = ?" args := []any{appCode} if query.Status != "" { whereSQL += " AND u.status = ?" args = append(args, query.Status) } if !query.UserFilter.IsEmpty() { matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", query.UserFilter, nowMs) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) } else if query.Keyword != "" { matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "u.user_id", query.Keyword, nowMs) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) } if query.Country != "" { // 国家筛选用 users.country 的 ISO code 精确命中;前端传展示名时不会被猜测转换,避免误筛到错误国家。 whereSQL += " AND UPPER(u.country) = ?" args = append(args, query.Country) } if query.RegionIDSet { if query.RegionID == 0 { // 列表展示里无区域或无有效业务区域都会显示为 GLOBAL;筛选 GLOBAL 时保持同一套展示口径。 whereSQL += " AND (COALESCE(u.region_id, 0) = 0 OR NOT " + appUserValidRegionExistsSQL("u") + ")" } else { whereSQL += " AND u.region_id = ?" args = append(args, query.RegionID) } } if query.StartMs > 0 { // 时间区间筛选的是用户创建时间,和列表“创建 / 活跃”列的第一行保持一致。 whereSQL += " AND u.created_at_ms >= ?" args = append(args, query.StartMs) } if query.EndMs > 0 { whereSQL += " AND u.created_at_ms < ?" args = append(args, query.EndMs) } if query.SnapshotAtMs > 0 { // 导出只读取任务创建时已经存在的用户,避免长任务执行期间的新注册插入到头部并改变分页集合。 whereSQL += " AND u.created_at_ms <= ?" args = append(args, query.SnapshotAtMs) } if query.ExportCursorCreatedAtMs > 0 && query.ExportCursorUserID > 0 { // 导出固定 created_at DESC/user_id DESC,组合游标处理同毫秒注册且不依赖 OFFSET。 whereSQL += " AND (u.created_at_ms < ? OR (u.created_at_ms = ? AND u.user_id < ?))" args = append(args, query.ExportCursorCreatedAtMs, query.ExportCursorCreatedAtMs, query.ExportCursorUserID) } return whereSQL, args } func appUserValidRegionExistsSQL(userAlias string) string { return fmt.Sprintf(`EXISTS ( SELECT 1 FROM regions rg WHERE rg.app_code = %[1]s.app_code AND rg.region_id = %[1]s.region_id AND rg.status = 'active' AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') LIMIT 1 )`, userAlias) } func appUserPrettySelectColumns() string { return `, COALESCE(( SELECT lease.display_user_id FROM pretty_display_user_id_leases lease WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id AND lease.status = 'active' AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1 ), ''), COALESCE(( SELECT pdi.pretty_id FROM pretty_display_user_id_leases lease JOIN pretty_display_ids pdi ON pdi.app_code = lease.app_code AND pdi.assigned_lease_id = lease.lease_id AND pdi.status = 'assigned' WHERE lease.app_code = u.app_code AND lease.user_id = u.user_id AND lease.status = 'active' AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1 ), '')` } func appUserSelectArgs(nowMs int64, whereArgs []any) []any { // 用户资料页展示“原始短号 + 靓号”时,靓号以租约表为准读取,不能只依赖 users.current_display_user_id。 // 本地 initdb 或历史修复脚本可能只重放 users 快照;active lease 和 pretty_display_ids 才是当前靓号归属的审计来源。 args := make([]any, 0, len(whereArgs)+2) args = append(args, nowMs, nowMs) args = append(args, whereArgs...) return args } func normalizeAppUserSortBy(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "coin", "coins": return "coin" case "created_at", "createdat", "created_at_ms", "createdatms", "created": return "created_at" default: return "created_at" } } func normalizeSortDirection(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "asc": return "asc" default: return "desc" } } func appUserOrderSQL(query listQuery) string { if query.SortBy == "created_at" && query.SortDirection == "asc" { return "ORDER BY u.created_at_ms ASC, u.user_id ASC" } return "ORDER BY u.created_at_ms DESC, u.user_id DESC" } func sortAppUsersByCoin(items []AppUser, direction string) { sort.SliceStable(items, func(i, j int) bool { left := items[i] right := items[j] if left.Coin != right.Coin { if direction == "asc" { return left.Coin < right.Coin } return left.Coin > right.Coin } // 金币相同的时候用内部用户 ID 做稳定排序,避免同余额用户在翻页和刷新时抖动。 leftID, _ := strconv.ParseInt(left.UserID, 10, 64) rightID, _ := strconv.ParseInt(right.UserID, 10, 64) if direction == "asc" { return leftID < rightID } return leftID > rightID }) } func paginateAppUsers(items []AppUser, page int, pageSize int) []AppUser { start := offset(page, pageSize) if start >= len(items) { return []AppUser{} } end := start + pageSize if end > len(items) { end = len(items) } return items[start:end] } func appUserNumericIDs(items []AppUser) []int64 { ids := make([]int64, 0, len(items)) for _, item := range items { id, err := strconv.ParseInt(item.UserID, 10, 64) if err != nil || id <= 0 { continue } ids = append(ids, id) } return ids } func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) { var total int64 err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total) return total, err } func offset(page int, pageSize int) int { return (page - 1) * pageSize } func nullableString(value string) sql.Null[string] { return sql.Null[string]{V: value, Valid: value != ""} } func normalizeCountryCode(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } func nowMillis() int64 { return time.Now().UnixMilli() } func rollbackIfOpen(tx *sql.Tx) { _ = tx.Rollback() }