fix:修改数据看板bug
This commit is contained in:
parent
d12a84c1f0
commit
c07e8e9c19
@ -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))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 环境变量覆盖)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
BIN
backend/services/statisticService/repository/statisticService
Normal file
BIN
backend/services/statisticService/repository/statisticService
Normal file
Binary file not shown.
@ -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)
|
||||
|
||||
BIN
backend/services/statisticService/service/statisticService
Normal file
BIN
backend/services/statisticService/service/statisticService
Normal file
Binary file not shown.
@ -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)))
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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.revenue(fire-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),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user