216 lines
7.3 KiB
Go
216 lines
7.3 KiB
Go
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
|
||
}
|