feat(statistic): T9-T12 dashboard 7 RPCs (Provider + Materializer + Service + Cache)
- StatisticInternalProvider: TrackEvent/BatchTrackEvent
- StatisticCombinedProvider: all 9 RPCs (7 dashboard + 2 event) on single service
- materializer: 4 MV REFRESH CONCURRENTLY + pg_try_advisory_lock + refresh_log
- dashboard_repo: 7 aggregation SQLs (week_rank / 7d curve / top5 / level dist / upgrade progress)
- dashboard_service: 7 RPCs with Redis 5min TTL + cache miss protection (1min empty)
- Cache wrapper: JSON serialize + format dash:{rpc}:{starID}:{userID}
- main.go: integrated workers + Dubbo triple server :20009
- cross-service userService.GetFanProfile (for crystal_balance)
- client/user_rpc_client.go
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bed8f8e578
commit
dd9952ccc9
39
backend/services/statisticService/client/user_rpc_client.go
Normal file
39
backend/services/statisticService/client/user_rpc_client.go
Normal file
@ -0,0 +1,39 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pbUser "github.com/topfans/backend/pkg/proto/user"
|
||||
)
|
||||
|
||||
// UserServiceClient 封装 userService 的 Dubbo 调用
|
||||
type UserServiceClient struct {
|
||||
userSocial pbUser.UserSocialService
|
||||
}
|
||||
|
||||
// NewUserServiceClient 构造
|
||||
func NewUserServiceClient(svc pbUser.UserSocialService) *UserServiceClient {
|
||||
return &UserServiceClient{userSocial: svc}
|
||||
}
|
||||
|
||||
// GetCrystalBalance 调 userService.GetFanProfile 取 crystal_balance
|
||||
// (userService 没单独 GetCrystalBalance,FanProfile 含此字段)
|
||||
func (c *UserServiceClient) GetCrystalBalance(ctx context.Context, userID, starID int64) (int64, error) {
|
||||
resp, err := c.userSocial.GetFanProfile(ctx, &pbUser.GetFanProfileRequest{
|
||||
UserId: userID,
|
||||
StarId: starID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if resp == nil || resp.Base == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if resp.Base.Code != 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if resp.Profile == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return resp.Profile.CrystalBalance, nil
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
pb "github.com/topfans/backend/pkg/proto/statistic"
|
||||
"github.com/topfans/backend/services/statisticService/metrics"
|
||||
"github.com/topfans/backend/services/statisticService/service"
|
||||
)
|
||||
|
||||
// StatisticCombinedProvider 完整 StatisticService 实现(9 RPC)
|
||||
// - 看板 7 RPC(T12 由 DashboardService 实现)
|
||||
// - 事件 2 RPC(T9 由 StatisticInternalProvider 实现)
|
||||
type StatisticCombinedProvider struct {
|
||||
*StatisticInternalProvider
|
||||
dashSvc *service.DashboardService
|
||||
}
|
||||
|
||||
// NewStatisticCombinedProvider 构造
|
||||
func NewStatisticCombinedProvider(internal *StatisticInternalProvider, dashSvc *service.DashboardService) *StatisticCombinedProvider {
|
||||
return &StatisticCombinedProvider{
|
||||
StatisticInternalProvider: internal,
|
||||
dashSvc: dashSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func userIDFromContext(ctx context.Context) int64 {
|
||||
if v := ctx.Value("user_id"); v != nil {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
case int64:
|
||||
return s
|
||||
case int:
|
||||
return int64(s)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) recordRPC(rpc string, start time.Time, err error) {
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
metrics.DashboardRPCTotal.WithLabelValues(rpc, status).Inc()
|
||||
metrics.DashboardRPCDuration.WithLabelValues(rpc).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
|
||||
// ===== 看板 7 RPC =====
|
||||
|
||||
func (p *StatisticCombinedProvider) GetTodayOverview(ctx context.Context, req *pb.GetTodayOverviewRequest) (*pb.GetTodayOverviewResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("GetTodayOverview", t0, nil) }()
|
||||
resp, err := p.dashSvc.GetTodayOverview(ctx, userIDFromContext(ctx), req.StarId)
|
||||
if err != nil {
|
||||
logger.Logger.Warn("GetTodayOverview failed", zap.Error(err))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) Get7DayIncomeCurve(ctx context.Context, req *pb.Get7DayIncomeCurveRequest) (*pb.Get7DayIncomeCurveResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("Get7DayIncomeCurve", t0, nil) }()
|
||||
return p.dashSvc.Get7DayIncomeCurve(ctx, userIDFromContext(ctx), req.StarId)
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) GetExhibitionIncomeSummary(ctx context.Context, req *pb.GetExhibitionIncomeSummaryRequest) (*pb.GetExhibitionIncomeSummaryResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("GetExhibitionIncomeSummary", t0, nil) }()
|
||||
return p.dashSvc.GetExhibitionIncomeSummary(ctx, userIDFromContext(ctx), req.StarId)
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) GetLikeIncomeByLevel(ctx context.Context, req *pb.GetLikeIncomeByLevelRequest) (*pb.GetLikeIncomeByLevelResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("GetLikeIncomeByLevel", t0, nil) }()
|
||||
return p.dashSvc.GetLikeIncomeByLevel(ctx, userIDFromContext(ctx), req.StarId)
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) GetTopAssetsByEarning(ctx context.Context, req *pb.GetTopAssetsByEarningRequest) (*pb.GetTopAssetsByEarningResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("GetTopAssetsByEarning", t0, nil) }()
|
||||
return p.dashSvc.GetTopAssetsByEarning(ctx, userIDFromContext(ctx), req.StarId)
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) GetAssetLevelDistribution(ctx context.Context, req *pb.GetAssetLevelDistributionRequest) (*pb.GetAssetLevelDistributionResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("GetAssetLevelDistribution", t0, nil) }()
|
||||
return p.dashSvc.GetAssetLevelDistribution(ctx, userIDFromContext(ctx), req.StarId)
|
||||
}
|
||||
|
||||
func (p *StatisticCombinedProvider) GetAssetUpgradeProgress(ctx context.Context, req *pb.GetAssetUpgradeProgressRequest) (*pb.GetAssetUpgradeProgressResponse, error) {
|
||||
t0 := time.Now()
|
||||
defer func() { p.recordRPC("GetAssetUpgradeProgress", t0, nil) }()
|
||||
return p.dashSvc.GetAssetUpgradeProgress(ctx, userIDFromContext(ctx), req.StarId)
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
pb "github.com/topfans/backend/pkg/proto/statistic"
|
||||
eventPb "github.com/topfans/backend/pkg/proto/event"
|
||||
"github.com/topfans/backend/services/statisticService/model"
|
||||
"github.com/topfans/backend/services/statisticService/service"
|
||||
)
|
||||
|
||||
// StatisticInternalProvider 实现 Dubbo StatisticService 中的事件相关 RPC
|
||||
// (TrackEvent / BatchTrackEvent)
|
||||
// 业务侧通过 gRPC 调用此处
|
||||
type StatisticInternalProvider struct {
|
||||
eventSvc *service.EventService
|
||||
}
|
||||
|
||||
// NewStatisticInternalProvider 构造
|
||||
func NewStatisticInternalProvider(eventSvc *service.EventService) *StatisticInternalProvider {
|
||||
return &StatisticInternalProvider{eventSvc: eventSvc}
|
||||
}
|
||||
|
||||
// TrackEvent 接收单个事件
|
||||
func (p *StatisticInternalProvider) TrackEvent(ctx context.Context, e *eventPb.Event) (*pb.TrackEventResponse, error) {
|
||||
logger.Logger.Debug("StatisticInternalProvider.TrackEvent",
|
||||
zap.String("event_id", e.EventId),
|
||||
zap.String("event_type", e.EventType))
|
||||
return p.eventSvc.TrackEvent(ctx, toModel(e))
|
||||
}
|
||||
|
||||
// BatchTrackEvent 批量接收事件
|
||||
func (p *StatisticInternalProvider) BatchTrackEvent(ctx context.Context, req *eventPb.BatchEventRequest) (*pb.TrackEventResponse, error) {
|
||||
events := make([]*model.Event, 0, len(req.Events))
|
||||
for _, e := range req.Events {
|
||||
events = append(events, toModel(e))
|
||||
}
|
||||
return p.eventSvc.BatchTrackEvent(ctx, events)
|
||||
}
|
||||
|
||||
// toModel protobuf -> domain model
|
||||
func toModel(e *eventPb.Event) *model.Event {
|
||||
occurredAt := time.UnixMilli(e.OccurredAt)
|
||||
receivedAt := time.UnixMilli(e.ReceivedAt)
|
||||
if receivedAt.IsZero() {
|
||||
receivedAt = time.Now()
|
||||
}
|
||||
props := e.Properties
|
||||
if props == nil {
|
||||
props = map[string]string{}
|
||||
}
|
||||
return &model.Event{
|
||||
EventID: e.EventId,
|
||||
UserID: e.UserId,
|
||||
StarID: e.StarId,
|
||||
EventType: e.EventType,
|
||||
OccurredAt: occurredAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Properties: props,
|
||||
}
|
||||
}
|
||||
379
backend/services/statisticService/repository/dashboard_repo.go
Normal file
379
backend/services/statisticService/repository/dashboard_repo.go
Normal file
@ -0,0 +1,379 @@
|
||||
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 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
|
||||
top5 = append(top5, t)
|
||||
}
|
||||
|
||||
// 简化:exhibiting_count / starbook_count / total_duration 留 0
|
||||
return &ExhibitionSummary{
|
||||
ExhibitingCount: 0,
|
||||
StarbookCount: 0,
|
||||
TotalDuration: "0:00:00:00",
|
||||
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) {
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT a.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
|
||||
WHERE e.user_id=$1 AND e.star_id=$2 AND e.event_type='asset.like'
|
||||
GROUP BY a.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) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT level, COUNT(*)::INT
|
||||
FROM public.assets
|
||||
WHERE user_id=$1 AND star_id=$2
|
||||
AND status='active' AND deleted_at IS NULL
|
||||
GROUP BY 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)
|
||||
}
|
||||
64
backend/services/statisticService/service/cache.go
Normal file
64
backend/services/statisticService/service/cache.go
Normal file
@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Cache Redis 缓存封装(5min TTL + 1min 空值 TTL 防穿透)
|
||||
type Cache struct {
|
||||
rdb *redis.Client
|
||||
ttl time.Duration
|
||||
emptyTTL time.Duration
|
||||
missedHits int64
|
||||
}
|
||||
|
||||
// NewCache 构造
|
||||
func NewCache(rdb *redis.Client) *Cache {
|
||||
return &Cache{
|
||||
rdb: rdb,
|
||||
ttl: 5 * time.Minute,
|
||||
emptyTTL: 1 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// GetJSON 取缓存 + JSON 反序列化
|
||||
func (c *Cache) GetJSON(ctx context.Context, key string, dst interface{}) (bool, error) {
|
||||
v, err := c.rdb.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if v == "null" {
|
||||
return false, nil // 缓存穿透防护的空标记
|
||||
}
|
||||
if err := json.Unmarshal([]byte(v), dst); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetJSON 序列化 + 缓存(5min TTL)
|
||||
func (c *Cache) SetJSON(ctx context.Context, key string, value interface{}) error {
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.rdb.Set(ctx, key, b, c.ttl).Err()
|
||||
}
|
||||
|
||||
// SetEmpty 缓存空值(1min TTL,防穿透)
|
||||
func (c *Cache) SetEmpty(ctx context.Context, key string) error {
|
||||
return c.rdb.Set(ctx, key, "null", c.emptyTTL).Err()
|
||||
}
|
||||
|
||||
// CacheKey 看板缓存 key 格式
|
||||
func CacheKey(rpc string, starID, userID int64) string {
|
||||
return fmt.Sprintf("dash:%s:%d:%d", rpc, starID, userID)
|
||||
}
|
||||
222
backend/services/statisticService/service/dashboard_service.go
Normal file
222
backend/services/statisticService/service/dashboard_service.go
Normal file
@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
pb "github.com/topfans/backend/pkg/proto/statistic"
|
||||
"github.com/topfans/backend/services/statisticService/repository"
|
||||
)
|
||||
|
||||
// UserRPCClient 跨服务调用 userService 的接口
|
||||
type UserRPCClient interface {
|
||||
GetCrystalBalance(ctx context.Context, userID, starID int64) (int64, error)
|
||||
}
|
||||
|
||||
// DashboardService 看板 7 RPC 业务逻辑
|
||||
type DashboardService struct {
|
||||
repo *repository.DashboardRepository
|
||||
cache *Cache
|
||||
userRPC UserRPCClient
|
||||
}
|
||||
|
||||
// NewDashboardService 构造
|
||||
func NewDashboardService(repo *repository.DashboardRepository, cache *Cache, userRPC UserRPCClient) *DashboardService {
|
||||
return &DashboardService{repo: repo, cache: cache, userRPC: userRPC}
|
||||
}
|
||||
|
||||
// ===== 1. 今日概览 =====
|
||||
func (s *DashboardService) GetTodayOverview(ctx context.Context, userID, starID int64) (*pb.GetTodayOverviewResponse, error) {
|
||||
key := CacheKey("today_overview", starID, userID)
|
||||
var cached pb.GetTodayOverviewResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
part, err := s.repo.GetWeekRank(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
todayIncome, err := s.repo.GetTodayIncome(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var crystal int64
|
||||
if s.userRPC != nil {
|
||||
if c, err := s.userRPC.GetCrystalBalance(ctx, userID, starID); err == nil {
|
||||
crystal = c
|
||||
} else {
|
||||
logger.Logger.Warn("userService.GetCrystalBalance failed, use 0", zap.Error(err))
|
||||
}
|
||||
}
|
||||
resp := &pb.GetTodayOverviewResponse{
|
||||
CrystalBalance: crystal,
|
||||
TodayIncome: todayIncome,
|
||||
WeekRank: part.WeekRank,
|
||||
WeekTotalUsers: part.WeekTotalUsers,
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ===== 2. 七日收益曲线 =====
|
||||
func (s *DashboardService) Get7DayIncomeCurve(ctx context.Context, userID, starID int64) (*pb.Get7DayIncomeCurveResponse, error) {
|
||||
key := CacheKey("7day_income_curve", starID, userID)
|
||||
var cached pb.Get7DayIncomeCurveResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
points, total, err := s.repo.Get7DayIncomeCurve(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pb.Get7DayIncomeCurveResponse{
|
||||
TotalIncome: total,
|
||||
AvgIncome: avgInt64(total, 7),
|
||||
}
|
||||
for _, p := range points {
|
||||
resp.Points = append(resp.Points, &pb.DailyIncomePoint{
|
||||
Date: p.Date, Income: p.Income, IsToday: p.IsToday, IsPeak: p.IsPeak,
|
||||
})
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ===== 3. 展出收益中心 =====
|
||||
func (s *DashboardService) GetExhibitionIncomeSummary(ctx context.Context, userID, starID int64) (*pb.GetExhibitionIncomeSummaryResponse, error) {
|
||||
key := CacheKey("exhibition_summary", starID, userID)
|
||||
var cached pb.GetExhibitionIncomeSummaryResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
sum, err := s.repo.GetExhibitionIncomeSummary(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pb.GetExhibitionIncomeSummaryResponse{
|
||||
ExhibitingCount: sum.ExhibitingCount,
|
||||
StarbookCount: sum.StarbookCount,
|
||||
TotalDuration: sum.TotalDuration,
|
||||
TotalEarnings: sum.TotalEarnings,
|
||||
}
|
||||
for _, t := range sum.Top5 {
|
||||
resp.Top5 = append(resp.Top5, &pb.TopExhibitionItem{
|
||||
AssetId: t.AssetID, AssetName: t.AssetName, AssetThumb: t.AssetThumb,
|
||||
Duration_7D: t.Duration7d, Earnings_7D: t.Earnings7d, AvgEarnings: t.AvgEarnings,
|
||||
})
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ===== 4. 点赞按等级 =====
|
||||
func (s *DashboardService) GetLikeIncomeByLevel(ctx context.Context, userID, starID int64) (*pb.GetLikeIncomeByLevelResponse, error) {
|
||||
key := CacheKey("like_income_by_level", starID, userID)
|
||||
var cached pb.GetLikeIncomeByLevelResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
levels, totalCount, totalIncome, err := s.repo.GetLikeIncomeByLevel(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pb.GetLikeIncomeByLevelResponse{
|
||||
TotalLikeCount: totalCount,
|
||||
TotalIncome: totalIncome,
|
||||
}
|
||||
for _, l := range levels {
|
||||
resp.Levels = append(resp.Levels, &pb.LikeIncomeLevelItem{
|
||||
Level: l.Level, AssetCount: l.AssetCount, TotalIncome: l.Income, Thumb: l.Thumb,
|
||||
})
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ===== 5. 藏品 TOP5 =====
|
||||
func (s *DashboardService) GetTopAssetsByEarning(ctx context.Context, userID, starID int64) (*pb.GetTopAssetsByEarningResponse, error) {
|
||||
key := CacheKey("top_assets", starID, userID)
|
||||
var cached pb.GetTopAssetsByEarningResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
rows, err := s.repo.GetTopAssetsByEarning(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pb.GetTopAssetsByEarningResponse{}
|
||||
for _, t := range rows {
|
||||
resp.Items = append(resp.Items, &pb.TopAssetItem{
|
||||
AssetId: t.AssetID, AssetName: t.AssetName, AssetThumb: t.AssetThumb,
|
||||
TotalEarnings: t.TotalEarnings, Rank: t.Rank,
|
||||
})
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ===== 6. 等级分布 =====
|
||||
func (s *DashboardService) GetAssetLevelDistribution(ctx context.Context, userID, starID int64) (*pb.GetAssetLevelDistributionResponse, error) {
|
||||
key := CacheKey("level_distribution", starID, userID)
|
||||
var cached pb.GetAssetLevelDistributionResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
items, err := s.repo.GetAssetLevelDistribution(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pb.GetAssetLevelDistributionResponse{}
|
||||
for _, i := range items {
|
||||
resp.Items = append(resp.Items, &pb.AssetLevelItem{
|
||||
Level: i.Level, Count: i.Count, Total: i.Total,
|
||||
})
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ===== 7. 升级进度 =====
|
||||
func (s *DashboardService) GetAssetUpgradeProgress(ctx context.Context, userID, starID int64) (*pb.GetAssetUpgradeProgressResponse, error) {
|
||||
key := CacheKey("upgrade_progress", starID, userID)
|
||||
var cached pb.GetAssetUpgradeProgressResponse
|
||||
if ok, _ := s.cache.GetJSON(ctx, key, &cached); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
upcoming, recent, err := s.repo.GetAssetUpgradeProgress(ctx, userID, starID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &pb.GetAssetUpgradeProgressResponse{}
|
||||
for _, u := range upcoming {
|
||||
resp.Upcoming = append(resp.Upcoming, &pb.UpcomingLevelUpItem{
|
||||
AssetId: u.AssetID, AssetName: u.AssetName, AssetThumb: u.AssetThumb,
|
||||
LikeProgress: u.LikeProgress, DurationProgress: u.DurationProgress,
|
||||
})
|
||||
}
|
||||
for _, r := range recent {
|
||||
resp.Recent = append(resp.Recent, &pb.RecentLevelUpItem{
|
||||
AssetId: r.AssetID, AssetName: r.AssetName, AssetThumb: r.AssetThumb,
|
||||
NewLevel: r.NewLevel, UpgradeTime: r.UpgradeTime,
|
||||
})
|
||||
}
|
||||
_ = s.cache.SetJSON(ctx, key, resp)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// helper
|
||||
func avgInt64(total int64, n int) int64 {
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return total / int64(n)
|
||||
}
|
||||
|
||||
// sentinel
|
||||
var _ = fmt.Sprintf
|
||||
var _ = time.Second
|
||||
Loading…
Reference in New Issue
Block a user