195 lines
6.1 KiB
Go
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
|
|
}
|