2026-07-14 20:23:12 +08:00

214 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
// login_audit 的复合索引把 login_type 放在 created_at_ms 之前;若把三种登录类型写成 IN
// MySQL 必须为每个用户扫描并排序全部候选,线上百万级审计表会超时。这里按类型拆成三个等值索引
// 范围,每个范围只取一条,再对最多三条候选按 (created_at_ms, id) 选最新记录,既保持原口径,
// 也避免窗口函数物化整张审计表。版本过滤必须放在 latest 选定之后:若先过滤审计行,最新登录
// 没带版本的用户会错误回退到旧版本。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
rows, err := s.userDB.QueryContext(ctx, `
SELECT TRIM(latest.app_version) AS version_key,
COUNT(*) AS user_count
FROM users AS natural_user
LEFT JOIN LATERAL (
SELECT candidate.app_version
FROM (
(SELECT audit.app_version, audit.created_at_ms, audit.id
FROM login_audit AS audit
WHERE audit.app_code = natural_user.app_code
AND audit.user_id = natural_user.user_id
AND audit.result = 'success'
AND audit.blocked = 0
AND audit.login_type = 'password'
ORDER BY audit.created_at_ms DESC, audit.id DESC
LIMIT 1)
UNION ALL
(SELECT audit.app_version, audit.created_at_ms, audit.id
FROM login_audit AS audit
WHERE audit.app_code = natural_user.app_code
AND audit.user_id = natural_user.user_id
AND audit.result = 'success'
AND audit.blocked = 0
AND audit.login_type = 'third_party'
ORDER BY audit.created_at_ms DESC, audit.id DESC
LIMIT 1)
UNION ALL
(SELECT audit.app_version, audit.created_at_ms, audit.id
FROM login_audit AS audit
WHERE audit.app_code = natural_user.app_code
AND audit.user_id = natural_user.user_id
AND audit.result = 'success'
AND audit.blocked = 0
AND audit.login_type = 'refresh'
ORDER BY audit.created_at_ms DESC, audit.id DESC
LIMIT 1)
) AS candidate
ORDER BY candidate.created_at_ms DESC, candidate.id DESC
LIMIT 1
) AS latest ON TRUE
WHERE natural_user.app_code = ?
AND natural_user.profile_completed = 1
AND LOWER(TRIM(COALESCE(natural_user.source, ''))) NOT IN ('game_robot', 'quick_account')
AND latest.app_version IS NOT NULL
AND TRIM(latest.app_version) <> ''
AND LOWER(TRIM(latest.app_version)) NOT IN ('unknown', '<nil>')
GROUP BY version_key
ORDER BY user_count DESC, version_key ASC`, 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)
// SQL 已排除所有无版本用户;这里仍守住响应边界,避免测试替身或历史脏值把“未知版本”带回前端。
if key == "unknown" {
continue
}
items = append(items, UserProfileDistributionItem{Key: key, Label: 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 == "<nil>" {
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
}
}