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) // LEFT JOIN assets 取 name/cover_url;event 里 asset_id 可能为 null/非数字,需要 WHERE 防御 rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` SELECT (e.properties->>'asset_id')::BIGINT AS asset_id, COALESCE(MIN(a.name), '') AS asset_name, COALESCE(MIN(a.cover_url), '') AS asset_thumb, SUM((e.properties->>'amount')::BIGINT) AS earnings, SUM((e.properties->>'duration_ms')::BIGINT) AS duration_ms 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' AND (e.properties->>'asset_id') IS NOT NULL AND (e.properties->>'asset_id') ~ '^[0-9]+$' GROUP BY (e.properties->>'asset_id')::BIGINT 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.AssetName, &t.AssetThumb, &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) } // 展出中:当前 active 的 exhibitions(未删除 + 未过期) var exhibitingCount int32 if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*)::INT FROM public.exhibitions WHERE occupier_uid=$1 AND occupier_star_id=$2 AND deleted_at IS NULL AND expire_at > EXTRACT(EPOCH FROM NOW()) * 1000 `, userID, starID).Scan(&exhibitingCount); err != nil { exhibitingCount = 0 } // 星册数:该用户在此 star 下启用的 booth_slots 总数 var starbookCount int32 if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*)::INT FROM public.booth_slots WHERE user_id=$1 AND star_id=$2 AND is_enabled = TRUE `, userID, starID).Scan(&starbookCount); err != nil { starbookCount = 0 } return &ExhibitionSummary{ ExhibitingCount: exhibitingCount, StarbookCount: starbookCount, 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) }