topfans/backend/services/statisticService/repository/dashboard_repo.go
2026-06-09 00:37:42 +08:00

391 lines
11 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 repository
import (
"context"
"database/sql"
"fmt"
"time"
)
// DashboardRepository 看板数据访问
type DashboardRepository struct {
db *sql.DB
schema string
}
// NewDashboardRepository 构造
func NewDashboardRepository(db *sql.DB, schema string) *DashboardRepository {
return &DashboardRepository{db: db, schema: schema}
}
// TodayOverviewPart week_rank + week_total_users
type TodayOverviewPart struct {
WeekRank int32
WeekTotalUsers int32
}
// GetWeekRank + week total users从预聚表
func (r *DashboardRepository) GetWeekRank(ctx context.Context, userID, starID int64) (*TodayOverviewPart, error) {
weekStart := weekStartMonday(time.Now())
var rank sql.NullInt64
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT rank_in_star FROM %s.metric_weekly_user_income
WHERE star_id=$1 AND user_id=$2 AND week_start=$3
`, r.schema), starID, userID, weekStart).Scan(&rank)
if err == sql.ErrNoRows {
return &TodayOverviewPart{WeekRank: -1, WeekTotalUsers: 0}, nil
}
if err != nil {
return nil, err
}
var totalUsers int32
err = r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COUNT(*)::INT FROM %s.metric_weekly_user_income
WHERE star_id=$1 AND week_start=$2 AND total_crystal > 0
`, r.schema), starID, weekStart).Scan(&totalUsers)
if err != nil {
return nil, err
}
return &TodayOverviewPart{
WeekRank: int32(rank.Int64),
WeekTotalUsers: totalUsers,
}, nil
}
// GetTodayIncome 今日收入
func (r *DashboardRepository) GetTodayIncome(ctx context.Context, userID, starID int64) (int64, error) {
var income sql.NullInt64
err := r.db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT COALESCE(SUM(CASE WHEN (properties->>'amount')::BIGINT > 0
THEN (properties->>'amount')::BIGINT ELSE 0 END), 0)
FROM %s.events
WHERE user_id=$1 AND star_id=$2
AND event_type IN ('exhibition.revenue', 'crystal.change')
AND received_at >= DATE_TRUNC('day', NOW() AT TIME ZONE 'Asia/Shanghai')
`, r.schema), userID, starID).Scan(&income)
if err != nil && err != sql.ErrNoRows {
return 0, err
}
return income.Int64, nil
}
// DailyIncomePoint 七日曲线
type DailyIncomePoint struct {
Date string
Income int64
IsToday bool
IsPeak bool
}
// Get7DayIncomeCurve 七日收益曲线
func (r *DashboardRepository) Get7DayIncomeCurve(ctx context.Context, userID, starID int64) ([]DailyIncomePoint, int64, error) {
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT income_date::text, COALESCE(total_crystal, 0)
FROM %s.mv_daily_user_income
WHERE user_id=$1 AND star_id=$2
AND income_date >= (DATE_TRUNC('day', NOW() AT TIME ZONE 'Asia/Shanghai') - INTERVAL '6 days')::date
ORDER BY income_date ASC
`, r.schema), userID, starID)
if err != nil {
return nil, 0, err
}
defer rows.Close()
points := make([]DailyIncomePoint, 0, 7)
var total, peak int64
for rows.Next() {
var p DailyIncomePoint
if err := rows.Scan(&p.Date, &p.Income); err != nil {
return nil, 0, err
}
points = append(points, p)
total += p.Income
if p.Income > peak {
peak = p.Income
}
}
today := time.Now().In(shanghaiLoc()).Format("2006-01-02")
for i := range points {
points[i].IsToday = points[i].Date == today
points[i].IsPeak = points[i].Income == peak && peak > 0
}
return points, total, nil
}
// GetExhibitionIncomeSummary 展出收益中心
type ExhibitionSummary struct {
ExhibitingCount int32
StarbookCount int32
TotalDuration string
TotalEarnings int64
Top5 []TopExhibitionRow
}
// TopExhibitionRow top5 藏品
type TopExhibitionRow struct {
AssetID int64
AssetName string
AssetThumb string
Duration7d string
Earnings7d int64
AvgEarnings int32
}
func (r *DashboardRepository) GetExhibitionIncomeSummary(ctx context.Context, userID, starID int64) (*ExhibitionSummary, error) {
// 简化实现:直接统计 events生产环境用 mv_daily_exhibition_revenue
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT
(properties->>'asset_id')::BIGINT AS asset_id,
SUM((properties->>'amount')::BIGINT) AS earnings,
SUM((properties->>'duration_ms')::BIGINT) AS duration_ms
FROM %s.events
WHERE user_id=$1 AND star_id=$2
AND event_type='exhibition.revenue'
AND received_at >= NOW() - INTERVAL '7 days'
GROUP BY asset_id
ORDER BY earnings DESC
LIMIT 5
`, r.schema), userID, starID)
if err != nil {
return nil, err
}
defer rows.Close()
var top5 []TopExhibitionRow
var totalEarnings, totalDurationMs int64
for rows.Next() {
var t TopExhibitionRow
var durationMs int64
if err := rows.Scan(&t.AssetID, &t.Earnings7d, &durationMs); err != nil {
return nil, err
}
t.Duration7d = formatDuration(durationMs)
if t.Earnings7d > 0 {
t.AvgEarnings = int32(t.Earnings7d / 7)
}
totalEarnings += t.Earnings7d
totalDurationMs += durationMs
top5 = append(top5, t)
}
// 简化exhibiting_count / starbook_count 留 0
return &ExhibitionSummary{
ExhibitingCount: 0,
StarbookCount: 0,
TotalDuration: formatDuration(totalDurationMs),
TotalEarnings: totalEarnings,
Top5: top5,
}, nil
}
// GetLikeIncomeByLevel 点赞按等级
type LikeIncomeLevelRow struct {
Level string
AssetCount int32
Income int64
Thumb string
}
func (r *DashboardRepository) GetLikeIncomeByLevel(ctx context.Context, userID, starID int64) ([]LikeIncomeLevelRow, int64, int64, error) {
// 前端期望 N/R/SR/SSR/UR 升级等级徽章,所以 JOIN asset_level_records 拿升级等级
// (不再用 assets.grade那只是星册整数 1-5 等级)
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT COALESCE(alr.current_level, 'UNKNOWN') AS level, COUNT(*), SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)), COALESCE(MIN(a.cover_url), '')
FROM %s.events e
JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT
LEFT JOIN public.asset_level_records alr ON alr.asset_id = a.id
WHERE e.user_id=$1 AND e.star_id=$2 AND e.event_type='asset.like'
AND (e.properties->>'asset_id') IS NOT NULL
AND (e.properties->>'asset_id') ~ '^[0-9]+$'
GROUP BY alr.current_level
ORDER BY SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)) DESC
`, r.schema), userID, starID)
if err != nil {
return nil, 0, 0, err
}
defer rows.Close()
var levels []LikeIncomeLevelRow
var totalIncome, totalCount int64
for rows.Next() {
var r LikeIncomeLevelRow
if err := rows.Scan(&r.Level, &r.AssetCount, &r.Income, &r.Thumb); err != nil {
return nil, 0, 0, err
}
levels = append(levels, r)
totalIncome += r.Income
totalCount += int64(r.AssetCount)
}
return levels, totalCount, totalIncome, nil
}
// GetTopAssetsByEarning 藏品 TOP5
type TopAssetRow struct {
AssetID int64
AssetName string
AssetThumb string
TotalEarnings int64
Rank int32
}
func (r *DashboardRepository) GetTopAssetsByEarning(ctx context.Context, userID, starID int64) ([]TopAssetRow, error) {
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT
(properties->>'asset_id')::BIGINT,
COALESCE(MIN(a.name), ''),
COALESCE(MIN(a.cover_url), ''),
SUM((properties->>'amount')::BIGINT)
FROM %s.events e LEFT JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT
WHERE e.user_id=$1 AND e.star_id=$2
AND e.event_type='exhibition.revenue'
AND e.received_at >= NOW() - INTERVAL '7 days'
GROUP BY (properties->>'asset_id')::BIGINT
ORDER BY SUM((properties->>'amount')::BIGINT) DESC
LIMIT 5
`, r.schema), userID, starID)
if err != nil {
return nil, err
}
defer rows.Close()
var rows2 []TopAssetRow
var rank int32 = 1
for rows.Next() {
var t TopAssetRow
if err := rows.Scan(&t.AssetID, &t.AssetName, &t.AssetThumb, &t.TotalEarnings); err != nil {
return nil, err
}
t.Rank = rank
rank++
rows2 = append(rows2, t)
}
return rows2, nil
}
// GetAssetLevelDistribution 等级分布
type AssetLevelCount struct {
Level string
Count int32
Total int32
}
func (r *DashboardRepository) GetAssetLevelDistribution(ctx context.Context, userID, starID int64) ([]AssetLevelCount, error) {
// 前端期望 N/R/SR/SSR/UR 升级等级(带徽章和颜色映射),不是 assets.grade 整数
// 资产升级进度存在 asset_level_records.current_level通过 level_up 事件维护)
// 过滤掉没 level_records 的资产(前端没有对应徽章,会显示为 N fallback 误导)
rows, err := r.db.QueryContext(ctx, `
SELECT alr.current_level AS level, COUNT(*)::INT
FROM public.assets a
INNER JOIN public.asset_level_records alr ON alr.asset_id = a.id
WHERE a.owner_uid=$1 AND a.star_id=$2
AND a.is_active=TRUE AND a.deleted_at IS NULL
GROUP BY alr.current_level
ORDER BY COUNT(*) DESC
`, userID, starID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AssetLevelCount
var total int32
for rows.Next() {
var a AssetLevelCount
if err := rows.Scan(&a.Level, &a.Count); err != nil {
return nil, err
}
total += a.Count
out = append(out, a)
}
for i := range out {
out[i].Total = total
}
return out, nil
}
// GetAssetUpgradeProgress 升级进度
type UpcomingLevelUpRow struct {
AssetID int64
AssetName string
AssetThumb string
LikeProgress int32
DurationProgress int32
}
type RecentLevelUpRow struct {
AssetID int64
AssetName string
AssetThumb string
NewLevel string
UpgradeTime int64
}
func (r *DashboardRepository) GetAssetUpgradeProgress(ctx context.Context, userID, starID int64) ([]UpcomingLevelUpRow, []RecentLevelUpRow, error) {
// upcoming 从 metric_upcoming_level_ups
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT m.asset_id, COALESCE(a.name, ''), COALESCE(a.cover_url, ''), m.like_progress, m.duration_progress
FROM %s.metric_upcoming_level_ups m LEFT JOIN public.assets a ON a.id = m.asset_id
WHERE m.user_id=$1 AND m.star_id=$2
ORDER BY m.like_progress + m.duration_progress DESC
LIMIT 10
`, r.schema), userID, starID)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var upcoming []UpcomingLevelUpRow
for rows.Next() {
var u UpcomingLevelUpRow
if err := rows.Scan(&u.AssetID, &u.AssetName, &u.AssetThumb, &u.LikeProgress, &u.DurationProgress); err != nil {
return nil, nil, err
}
upcoming = append(upcoming, u)
}
// recent 从 metric_recent_level_ups
rows2, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT asset_id, COALESCE(asset_name, ''), COALESCE(asset_thumb, ''), to_level, EXTRACT(EPOCH FROM upgrade_time)::BIGINT
FROM %s.metric_recent_level_ups
WHERE user_id=$1 AND star_id=$2
AND upgrade_time >= NOW() - INTERVAL '30 days'
ORDER BY upgrade_time DESC
LIMIT 5
`, r.schema), userID, starID)
if err != nil {
return upcoming, nil, err
}
defer rows2.Close()
var recent []RecentLevelUpRow
for rows2.Next() {
var r RecentLevelUpRow
if err := rows2.Scan(&r.AssetID, &r.AssetName, &r.AssetThumb, &r.NewLevel, &r.UpgradeTime); err != nil {
return upcoming, nil, err
}
recent = append(recent, r)
}
return upcoming, recent, nil
}
// helpers
func shanghaiLoc() *time.Location {
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
return loc
}
func weekStartMonday(t time.Time) time.Time {
t2 := t.In(shanghaiLoc())
offset := (int(t2.Weekday()) + 6) % 7 // Mon=0
return time.Date(t2.Year(), t2.Month(), t2.Day()-offset, 0, 0, 0, 0, shanghaiLoc())
}
func formatDuration(ms int64) string {
totalSec := ms / 1000
h := totalSec / 3600
m := (totalSec % 3600) / 60
s := totalSec % 60
if h >= 24 {
d := h / 24
h = h % 24
return fmt.Sprintf("%d:%02d:%02d:%02d", d, h, m, s)
}
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}