fix:修改数据看板bug

This commit is contained in:
zerosaturation 2026-07-03 14:57:14 +08:00
parent d12a84c1f0
commit c07e8e9c19
11 changed files with 144 additions and 47 deletions

View File

@ -7,6 +7,9 @@ import (
dubboclient "dubbo.apache.org/dubbo-go/v3/client"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/topfans/backend/pkg/logger"
pb "github.com/topfans/backend/pkg/proto/event"
statisticPb "github.com/topfans/backend/pkg/proto/statistic"
)
@ -43,6 +46,10 @@ func Get() *Client { return instance }
// - 不阻塞业务方(独立 goroutine + background context
func (c *Client) TrackEvent(ctx context.Context, e *pb.Event) {
if c == nil || c.service == nil {
logger.Logger.Warn("TrackEvent: SDK not initialized, event dropped",
zap.String("event_type", e.EventType),
zap.Int64("user_id", e.UserId),
zap.Int64("star_id", e.StarId))
return
}
if e.EventId == "" {
@ -51,10 +58,19 @@ func (c *Client) TrackEvent(ctx context.Context, e *pb.Event) {
if e.OccurredAt == 0 {
e.OccurredAt = time.Now().UnixMilli()
}
eventType := e.EventType
eventID := e.EventId
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
go func() {
defer cancel()
_, _ = c.service.TrackEvent(bgCtx, e)
if _, err := c.service.TrackEvent(bgCtx, e); err != nil {
logger.Logger.Error("TrackEvent: gRPC call failed, event lost",
zap.String("event_id", eventID),
zap.String("event_type", eventType),
zap.Int64("user_id", e.UserId),
zap.Int64("star_id", e.StarId),
zap.Error(err))
}
}()
}

View File

@ -39,25 +39,10 @@ func RegisterHandlers() error {
//
// go mq.StartConsumers(ctx)
func StartConsumers(ctx context.Context) error {
// 周期性兜底扫描:替代被移除的 cleanup_worker
// 处理以下场景中遗漏的过期展览:
// 1. MQ 延迟任务因为进程被 kill 而丢失
// 2. EnqueueExhibitionExpire 调用时 Redis 暂时不可用
// 3. 任何其他导致延迟任务未投递/未触发的边界情况
// 一次性迁移扫描:处理 MQ 上线前创建的旧展览(它们没有延迟任务)
go func() {
time.Sleep(5 * time.Second)
scanExpiredExhibitions(context.Background())
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
scanExpiredExhibitions(context.Background())
case <-ctx.Done():
return
}
}
}()
return adapter.Get().TaskConsumer().Run(ctx)
}

View File

@ -91,13 +91,15 @@ func main() {
eventRepo := repository.NewEventRepository(db, config.DBConfig.Schema)
metricRepo := repository.NewMetricRepository(db, config.DBConfig.Schema)
// 8. 构造 sink + service
// 8. 构造 sink + service + cache
cs := sink.NewChannelEventSink(eventCh)
whiteList := service.DefaultEventTypeWhitelist
eventSvc := service.NewEventService(cs, whiteList)
cache := service.NewCache(rdb)
// 9. 启动 workers
flusher := worker.NewEventFlusher(eventCh, eventRepo, metricRepo,
cache,
config.ChannelCfg.EventBatchSize, config.ChannelCfg.EventBatchInterval)
go flusher.Start(context.Background())
@ -117,7 +119,6 @@ func main() {
// 看板 service需要 dashboard repo + cache + userService 跨服务客户端)
dashRepo := repository.NewDashboardRepository(db, config.DBConfig.Schema)
cache := service.NewCache(rdb)
// 跨服务 userService 客户端(用于 GetTodayOverview 调 crystal_balance
// 默认 URL: tri://localhost:20000可被 USER_SERVICE_URL 环境变量覆盖)

View File

@ -80,11 +80,23 @@ type DailyIncomePoint struct {
// 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
SELECT TO_CHAR(d.date, 'YYYY-MM-DD'), COALESCE(e.amount, 0)::BIGINT
FROM generate_series(
(DATE_TRUNC('day', NOW() AT TIME ZONE 'Asia/Shanghai') - INTERVAL '6 days')::date,
DATE_TRUNC('day', NOW() AT TIME ZONE 'Asia/Shanghai')::date,
INTERVAL '1 day'
) AS d(date)
LEFT JOIN (
SELECT (received_at AT TIME ZONE 'Asia/Shanghai')::date AS event_date,
SUM(CASE WHEN (properties->>'amount')::BIGINT > 0
THEN (properties->>'amount')::BIGINT ELSE 0 END) AS amount
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') - INTERVAL '6 days')
GROUP BY (received_at AT TIME ZONE 'Asia/Shanghai')::date
) e ON d.date = e.event_date
ORDER BY d.date ASC
`, r.schema), userID, starID)
if err != nil {
return nil, 0, err
@ -210,18 +222,36 @@ type LikeIncomeLevelRow struct {
}
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 等级)
// 两个数据源合并:
// 1. statistic.events 中的旧 asset.like 事件(含 amount旧 RPC 清理路径写入)
// 2. public.like_bet_revenue_records新 MQ 结算路径写入)
// 两者互不重叠(旧事件有 amount>0新事件 amount 为空),用 UNION ALL 安全合并
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
WITH like_income AS (
SELECT (e.properties->>'asset_id')::BIGINT AS asset_id,
COALESCE((e.properties->>'amount')::BIGINT, 0) AS amount
FROM %s.events e
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]+$'
AND COALESCE((e.properties->>'amount')::BIGINT, 0) > 0
UNION ALL
SELECT lbr.asset_id, lbr.crystal_amount AS amount
FROM public.like_bet_revenue_records lbr
WHERE lbr.user_id=$1 AND lbr.star_id=$2
)
SELECT COALESCE(alr.current_level, 'N') AS level,
COUNT(*) AS asset_count,
SUM(li.amount) AS income,
COALESCE(MIN(a.cover_url), '') AS thumb
FROM like_income li
JOIN public.assets a ON a.id = li.asset_id
LEFT JOIN public.asset_level_records alr ON alr.asset_id = li.asset_id
GROUP BY li.asset_id, alr.current_level
ORDER BY income DESC
LIMIT 5
`, r.schema), userID, starID)
if err != nil {
return nil, 0, 0, err
@ -408,10 +438,5 @@ func formatDuration(ms int64) string {
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)
}

View File

@ -58,6 +58,25 @@ func (c *Cache) SetEmpty(ctx context.Context, key string) error {
return c.rdb.Set(ctx, key, "null", c.emptyTTL).Err()
}
// Delete 删除单个缓存 key
func (c *Cache) Delete(ctx context.Context, key string) error {
return c.rdb.Del(ctx, key).Err()
}
// DeleteDashboardCache 删除指定 (starID, userID) 的所有看板缓存
// 在事件落库后调用,确保下次看板请求能读到最新数据
func (c *Cache) DeleteDashboardCache(ctx context.Context, starID, userID int64) error {
rpcKeys := []string{
"today_overview", "7day_income_curve", "exhibition_summary",
"like_income_by_level", "top_assets", "level_distribution", "upgrade_progress",
}
keys := make([]string, len(rpcKeys))
for i, rpc := range rpcKeys {
keys[i] = CacheKey(rpc, starID, userID)
}
return c.rdb.Del(ctx, keys...).Err()
}
// CacheKey 看板缓存 key 格式
func CacheKey(rpc string, starID, userID int64) string {
return fmt.Sprintf("dash:%s:%d:%d", rpc, starID, userID)

View File

@ -2,6 +2,7 @@ package worker
import (
"context"
"fmt"
"sync"
"time"
@ -11,16 +12,19 @@ import (
"github.com/topfans/backend/services/statisticService/metrics"
"github.com/topfans/backend/services/statisticService/model"
"github.com/topfans/backend/services/statisticService/repository"
"github.com/topfans/backend/services/statisticService/service"
)
// EventFlusher 攒批落库 worker
// - 从 channel 接收事件
// - 攒 batchSize 条 或 到 interval 时触发落库
// - 落库后同步触发 metric_recent_level_ups 更新(仅 asset.level_up 事件)
// - 落库后清除看板缓存,确保下次请求能读到最新数据
type EventFlusher struct {
ch <-chan *model.Event
eventRepo *repository.EventRepository
metricRepo *repository.MetricRepository
cache *service.Cache
batchSize int
interval time.Duration
@ -34,6 +38,7 @@ func NewEventFlusher(
ch <-chan *model.Event,
eventRepo *repository.EventRepository,
metricRepo *repository.MetricRepository,
cache *service.Cache,
batchSize int,
interval time.Duration,
) *EventFlusher {
@ -41,6 +46,7 @@ func NewEventFlusher(
ch: ch,
eventRepo: eventRepo,
metricRepo: metricRepo,
cache: cache,
batchSize: batchSize,
interval: interval,
stop: make(chan struct{}),
@ -77,6 +83,10 @@ func (f *EventFlusher) Start(ctx context.Context) {
zap.String("event_id", e.EventID), zap.Error(err))
}
}
// 清除看板缓存:让下次看板请求读到最新数据,不要等 5min TTL
if f.cache != nil {
f.invalidateDashboardCache(ctx, batch)
}
logger.Logger.Debug("event_flusher batch flushed",
zap.Int("inserted", inserted), zap.Int("batch", len(batch)))
batch = batch[:0]
@ -108,3 +118,28 @@ func (f *EventFlusher) Stop() {
f.running = false
}
}
// invalidateDashboardCache 清除一批事件涉及的所有 (user, star) 的看板缓存
func (f *EventFlusher) invalidateDashboardCache(ctx context.Context, events []*model.Event) {
seen := make(map[string]struct{})
for _, e := range events {
if e.UserID == 0 || e.StarID == 0 {
continue
}
key := fmt.Sprintf("%d:%d", e.UserID, e.StarID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if err := f.cache.DeleteDashboardCache(ctx, e.StarID, e.UserID); err != nil {
logger.Logger.Warn("invalidate dashboard cache failed",
zap.Int64("user_id", e.UserID),
zap.Int64("star_id", e.StarID),
zap.Error(err))
}
}
if len(seen) > 0 {
logger.Logger.Debug("dashboard cache invalidated",
zap.Int("pairs", len(seen)))
}
}

View File

@ -87,7 +87,7 @@ func TestEventFlusher_FlushBatch(t *testing.T) {
metricRepo := repository.NewMetricRepository(db, schema)
ch := make(chan *model.Event, 10)
flusher := NewEventFlusher(ch, eventRepo, metricRepo, 100, 1*time.Second)
flusher := NewEventFlusher(ch, eventRepo, metricRepo, nil, 100, 1*time.Second)
go flusher.Start(context.Background())
@ -119,7 +119,7 @@ func TestEventFlusher_TriggersMetricOnLevelUp(t *testing.T) {
ch := make(chan *model.Event, 10)
// 小 batch size 触发立即 flush
flusher := NewEventFlusher(ch, eventRepo, metricRepo, 1, 100*time.Millisecond)
flusher := NewEventFlusher(ch, eventRepo, metricRepo, nil, 1, 100*time.Millisecond)
go flusher.Start(context.Background())
@ -154,7 +154,7 @@ func TestEventFlusher_NoMetricOnNonLevelUp(t *testing.T) {
metricRepo := repository.NewMetricRepository(db, schema)
ch := make(chan *model.Event, 10)
flusher := NewEventFlusher(ch, eventRepo, metricRepo, 1, 100*time.Millisecond)
flusher := NewEventFlusher(ch, eventRepo, metricRepo, nil, 1, 100*time.Millisecond)
go flusher.Start(context.Background())

View File

@ -369,6 +369,14 @@ func (s *revenueService) ProcessExhibitionRevenue(ctx context.Context, params Pr
}
// 事件埋点
durationMs := actualHours * 3600 * 1000
if durationMs < 0 {
durationMs = 0
}
if params.AssetID <= 0 {
logger.Logger.Warn("ProcessExhibitionRevenue: asset_id is 0, event may lack asset_id",
zap.Int64("exhibition_id", params.ExhibitionID))
}
statistic.Get().TrackEvent(context.Background(), &event.Event{
EventType: "exhibition.revenue",
UserId: params.OccupierUID,
@ -377,7 +385,7 @@ func (s *revenueService) ProcessExhibitionRevenue(ctx context.Context, params Pr
Properties: map[string]string{
"asset_id": strconv.FormatInt(params.AssetID, 10),
"amount": strconv.FormatInt(finalRevenue, 10),
"duration_ms": strconv.FormatInt(actualHours*3600*1000, 10),
"duration_ms": strconv.FormatInt(durationMs, 10),
},
})
@ -520,6 +528,14 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
zap.Int64("revenue_record_id", createdRecord.ID))
// 事件埋点exhibition.revenuefire-and-forget
durationMs := actualHours * 3600 * 1000
if durationMs < 0 {
durationMs = 0
}
if req.AssetId <= 0 {
logger.Logger.Warn("OnExhibitionCompleted: asset_id is 0, event may lack asset_id",
zap.Int64("exhibition_id", req.ExhibitionId))
}
statistic.Get().TrackEvent(context.Background(), &event.Event{
EventType: "exhibition.revenue",
UserId: req.OccupierUid,
@ -527,8 +543,8 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
OccurredAt: time.Now().UnixMilli(),
Properties: map[string]string{
"asset_id": strconv.FormatInt(req.AssetId, 10),
"amount": strconv.FormatInt(crystalReward, 10),
"duration_ms": strconv.FormatInt(actualHours*3600*1000, 10),
"amount": strconv.FormatInt(finalRevenue, 10),
"duration_ms": strconv.FormatInt(durationMs, 10),
},
})

View File

@ -25,7 +25,7 @@
</view>
<view class="stat-block">
<text class="stat-num">{{ stats.total_income }}</text>
<text class="stat-text">累计收益</text>
<text class="stat-text">累计点赞收益</text>
</view>
</view>