package dashboard import ( "context" "fmt" "strings" "time" "hyapp-admin-server/internal/appctx" ) type UserProfileOverviewQuery struct { AppCode string StatTZ string StartMS int64 EndMS int64 } type UserProfileDistributionItem struct { Key string `json:"key"` Label string `json:"label"` Count int64 `json:"count"` } type UserProfileOverview struct { AppCode string `json:"appCode"` TotalUsers int64 `json:"totalUsers"` NewUsers int64 `json:"newUsers"` ActiveUsers int64 `json:"activeUsers"` GenderDistribution []UserProfileDistributionItem `json:"genderDistribution"` AppVersionDistribution []UserProfileDistributionItem `json:"appVersionDistribution"` UpdatedAtMS int64 `json:"updatedAtMs"` } func (s *DashboardService) UserProfileOverview(ctx context.Context, query UserProfileOverviewQuery) (UserProfileOverview, error) { if s == nil || s.userDB == nil { return UserProfileOverview{}, fmt.Errorf("user mysql is not configured") } query.AppCode = appctx.Normalize(query.AppCode) totalUsers, genderDistribution, err := s.queryNaturalUserGenderDistribution(ctx, query.AppCode) if err != nil { return UserProfileOverview{}, fmt.Errorf("query dashboard user profile: %w", err) } // 新增和 DAU 必须沿用数据大屏的事件聚合口径;直接按 users.created_at_ms 或登录表重算会与 // StatisticsOverview 的 UserRegistered/活跃事实去重规则产生双重口径。 statisticsOverview, err := s.StatisticsOverview(ctx, StatisticsQuery{ AppCode: query.AppCode, StatTZ: query.StatTZ, StartMS: query.StartMS, EndMS: query.EndMS, }) if err != nil { return UserProfileOverview{}, fmt.Errorf("query dashboard daily statistics: %w", err) } newUsers := anyInt64(statisticsOverview["new_users"]) activeUsers := anyInt64(statisticsOverview["active_users"]) versionDistribution, err := s.queryNaturalUserAppVersionDistribution(ctx, query.AppCode) if err != nil { return UserProfileOverview{}, fmt.Errorf("query dashboard app version distribution: %w", err) } return UserProfileOverview{ AppCode: query.AppCode, TotalUsers: totalUsers, NewUsers: newUsers, ActiveUsers: activeUsers, GenderDistribution: genderDistribution, AppVersionDistribution: versionDistribution, UpdatedAtMS: time.Now().UTC().UnixMilli(), }, nil } func (s *DashboardService) queryNaturalUserGenderDistribution(ctx context.Context, appCode string) (int64, []UserProfileDistributionItem, error) { rows, err := s.userDB.QueryContext(ctx, ` SELECT CASE WHEN TRIM(COALESCE(gender, '')) = '' THEN 'unknown' ELSE LOWER(TRIM(gender)) END AS gender_key, COUNT(*) AS user_count FROM users WHERE app_code = ? AND profile_completed = 1 AND LOWER(TRIM(COALESCE(source, ''))) NOT IN ('game_robot', 'quick_account') GROUP BY gender_key ORDER BY user_count DESC, gender_key ASC`, appCode) if err != nil { return 0, nil, err } defer rows.Close() // 总人数直接求和同一条性别分组结果,让 totalUsers 与饼图永远共享 profile_completed/source 条件; // 分开执行 COUNT 和 GROUP BY 容易在以后新增“真实用户”排除条件时只改到其中一处。 items := make([]UserProfileDistributionItem, 0) var total int64 for rows.Next() { var key string var count int64 if err := rows.Scan(&key, &count); err != nil { return 0, nil, err } key = normalizeDistributionKey(key) items = append(items, UserProfileDistributionItem{Key: key, Label: genderDistributionLabel(key), Count: count}) total += count } if err := rows.Err(); err != nil { return 0, nil, err } return total, items, nil } func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Context, appCode string) ([]UserProfileDistributionItem, error) { rows, err := s.userDB.QueryContext(ctx, ` WITH natural_users AS ( SELECT user_id FROM users WHERE app_code = ? AND profile_completed = 1 AND LOWER(TRIM(COALESCE(source, ''))) NOT IN ('game_robot', 'quick_account') ), ranked_login AS ( SELECT audit.user_id, TRIM(COALESCE(audit.app_version, '')) AS app_version, ROW_NUMBER() OVER ( PARTITION BY audit.user_id ORDER BY audit.created_at_ms DESC, audit.id DESC ) AS login_rank FROM login_audit AS audit INNER JOIN natural_users AS natural ON natural.user_id = audit.user_id WHERE audit.app_code = ? AND audit.result = 'success' AND audit.blocked = 0 AND audit.login_type IN ('password', 'third_party', 'refresh') ) SELECT CASE WHEN latest.app_version IS NULL OR latest.app_version = '' THEN 'unknown' ELSE latest.app_version END AS version_key, COUNT(*) AS user_count FROM natural_users AS natural LEFT JOIN ranked_login AS latest ON latest.user_id = natural.user_id AND latest.login_rank = 1 GROUP BY version_key ORDER BY user_count DESC, version_key ASC`, appCode, appCode) if err != nil { return nil, err } defer rows.Close() items := make([]UserProfileDistributionItem, 0) for rows.Next() { var key string var count int64 if err := rows.Scan(&key, &count); err != nil { return nil, err } key = normalizeDistributionKey(key) items = append(items, UserProfileDistributionItem{Key: key, Label: appVersionDistributionLabel(key), Count: count}) } if err := rows.Err(); err != nil { return nil, err } return items, nil } func normalizeDistributionKey(value string) string { value = strings.TrimSpace(value) if value == "" || strings.EqualFold(value, "unknown") || value == "" { return "unknown" } return value } func genderDistributionLabel(key string) string { switch strings.ToLower(key) { case "male": return "男" case "female": return "女" case "non_binary", "nonbinary": return "非二元" case "unknown": return "未知" default: return key } } func appVersionDistributionLabel(key string) string { if key == "unknown" { return "未知版本" } return key }