2026-07-12 00:47:20 +08:00

195 lines
6.1 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) {
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 == "<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
}