216 lines
7.3 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) 选最新记录,既保持原口径,
// 也避免窗口函数物化整张审计表。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
rows, err := s.userDB.QueryContext(ctx, `
SELECT CASE
WHEN latest.app_version IS NULL OR TRIM(latest.app_version) = '' THEN 'unknown'
ELSE TRIM(latest.app_version)
END 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')
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)
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 == "<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
}
}
func appVersionDistributionLabel(key string) string {
if key == "unknown" {
return "未知版本"
}
return key
}