391 lines
11 KiB
Go
391 lines
11 KiB
Go
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)
|
||
}
|