From e6767899c3c22d68b0a1f5212c9c7583bae3a388 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 10:04:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E6=8E=92=E8=A1=8C=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/flutter对接/用户排行榜Flutter对接.md | 43 +++++---- pkg/userleaderboard/store.go | 94 ++++++++++++++----- pkg/userleaderboard/store_test.go | 65 +++++++++++++ .../activity-service/internal/app/services.go | 7 +- .../internal/config/config.go | 5 +- .../internal/service/growth/service.go | 40 +++++++- .../internal/service/growth/service_test.go | 52 ++++++++++ .../activityapi/user_leaderboard_handler.go | 1 + .../user_leaderboard_handler_test.go | 55 +++++++++++ .../internal/transport/http/handler.go | 2 +- 10 files changed, 315 insertions(+), 49 deletions(-) diff --git a/docs/flutter对接/用户排行榜Flutter对接.md b/docs/flutter对接/用户排行榜Flutter对接.md index 4fe78485..81e07ac8 100644 --- a/docs/flutter对接/用户排行榜Flutter对接.md +++ b/docs/flutter对接/用户排行榜Flutter对接.md @@ -1,6 +1,6 @@ # 用户排行榜 Flutter 对接 -本文描述 Flutter App 对接用户排行榜、送礼榜、收礼榜和房间榜的 HTTP 接口。榜单事实来自 `wallet-service` 已成功的礼物扣费流水;客户端只展示 gateway 返回的榜单投影,不在本地重算排名、礼物价值或统计窗口。 +本文描述 Flutter App 对接财富榜、魅力榜、游戏榜和房间榜的 HTTP 接口。`sent/received` 来自钱包已提交的礼物事实,`game` 来自游戏成功扣币的 `game_spend_coin` 事实,`room` 来自 room-service 的房间收礼投影;客户端只展示 gateway 返回的榜单,不在本地重算排名或统计窗口。 ## 基础约定 @@ -49,7 +49,7 @@ Query 参数: | 字段 | 类型 | 必填 | 默认 | 说明 | | --- | --- | --- | --- | --- | -| `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼榜;`received` 是收礼榜;`room` 是房间收礼榜。 | +| `board_type` | string | 否 | `sent` | 榜单类型。`sent` 是送礼财富榜;`received` 是收礼魅力榜;`game` 是游戏消费榜;`room` 是房间收礼榜。 | | `period` | string | 否 | `today` | 统计周期。`today`、`week`、`month`。 | | `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 | | `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0;服务端最大返回 `100`。 | @@ -60,6 +60,7 @@ Query 参数: | --- | --- | --- | | `sent` | 空字符串、`send`、`sender`、`gift_sent`、`user_sent` | 按送礼用户聚合,展示用户送礼贡献。 | | `received` | `receive`、`receiver`、`gift_received`、`user_received` | 按收礼用户聚合,展示用户收礼魅力。 | +| `game` | `games`、`gaming` | 按用户聚合成功游戏扣币额;不累计派奖、退款或净输赢。 | | `room` | `rooms`、`room_gift`、`room_gifts` | 按房间聚合,展示房间礼物流水。 | `period` 兼容别名: @@ -70,7 +71,7 @@ Query 参数: | `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 | | `month` | `monthly` | UTC 本月 1 日 00:00:00 到当前服务端时间。 | -服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。排序固定为 `gift_value DESC, last_gift_at_ms DESC, subject_id ASC`;礼物价值相同的情况下,最近送礼时间更晚的排名更靠前,再用用户 ID 或房间 ID 保证稳定顺序。 +服务端实际查询范围是 `[start_at_ms, end_at_ms)`,其中 `end_at_ms` 是本次请求的当前服务端时间。用户榜按窗口累计值倒序;`game` 的累计值是 `game_spend_coin`,为兼容既有 JSON 暂时仍通过 `gift_value` 返回。房间榜由 room-service 按同一周期返回。 ### 送礼榜 @@ -92,6 +93,16 @@ X-App-Code: lalu 收礼榜按 `target_user_id` 聚合。返回项里的 `user_id` 和 `user` 表示收礼用户。 +### 游戏榜 + +```http +GET /api/v1/activities/user-leaderboards?board_type=game&period=today&page=1&page_size=20 +Authorization: Bearer +X-App-Code: huwaa +``` + +游戏榜按成功扣币订单的 `user_id` 聚合 `game_spend_coin`。只有 `op_type=debit` 且扣币成功、金额大于 0 的订单进入榜单;派奖、退款和净输赢不会改变游戏榜积分。返回项中的 `gift_value` 是兼容字段,游戏榜场景表示累计游戏扣币额。 + ### 房间榜 ```http @@ -104,7 +115,7 @@ X-App-Code: lalu ## 房间内贡献榜边界 -本接口是全局活动榜,按 `board_type + period` 查询成功送礼流水。房间页里的当前房间送礼贡献榜不是通过本接口读取: +本接口是全局活动榜,按 `board_type + period` 查询对应的用户或房间聚合。房间页里的当前房间送礼贡献榜不是通过本接口读取: | 场景 | 接口 | 榜单字段 | 说明 | | --- | --- | --- | --- | @@ -210,24 +221,24 @@ X-App-Code: lalu | `total` | int64 | 当前榜单和周期下的总上榜主体数。 | | `page` | int32 | 服务端解析后的页码。 | | `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 | -| `board_type` | string | 服务端归一化后的榜单类型:`sent`、`received`、`room`。 | +| `board_type` | string | 服务端归一化后的榜单类型:`sent`、`received`、`game`、`room`。 | | `period` | string | 服务端归一化后的统计周期:`today`、`week`、`month`。 | | `start_at_ms` | int64 | 本次统计窗口开始时间,UTC。 | | `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 | | `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 | -| `my_rank` | object? | 当前登录用户在送礼榜或收礼榜中的排名;未上榜或 `board_type=room` 时不返回。 | +| `my_rank` | object? | 当前登录用户在送礼榜、收礼榜或游戏榜中的排名;未上榜或 `board_type=room` 时不返回。 | `items[]` / `my_rank` 字段: | 字段 | 类型 | 说明 | | --- | --- | --- | | `rank` | int64 | 排名,从 1 开始。 | -| `user_id` | string | 用户榜单主体 ID;`sent`、`received` 才有。 | +| `user_id` | string | 用户榜单主体 ID;`sent`、`received`、`game` 才有。 | | `room_id` | string | 房间榜单主体 ID;`room` 才有。 | -| `gift_value` | int64 | 统计窗口内礼物价值总和,用于排名和主数值展示。 | -| `gift_count` | int64 | 统计窗口内礼物数量总和。 | -| `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 | -| `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 | +| `gift_value` | int64 | 排名累计值。`sent/received/room` 表示礼物价值;`game` 表示成功游戏扣币额。 | +| `gift_count` | int64 | 礼物榜的礼物数量总和;`game` 固定为 0。 | +| `transaction_count` | int64 | 用户榜的成功事实数;`game` 表示成功扣币订单数。 | +| `last_gift_at_ms` | int64 | 最近一次计榜事实时间;`game` 表示最近一次成功游戏扣币时间。字段名为兼容既有协议保留。 | | `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像;本地未注入用户资料 client 时至少返回 `user_id`。 | | `room` | object? | 房间基础投影;房间榜返回 `room_id`、`room_short_id`、`title`、`cover_url`、`room_avatar`。 | @@ -415,7 +426,7 @@ class LeaderboardRoom { 请求封装示例: ```dart -enum LeaderboardType { sent, received, room } +enum LeaderboardType { sent, received, game, room } enum LeaderboardPeriod { today, week, month } extension on LeaderboardType { @@ -493,15 +504,15 @@ class ApiException implements Exception { | `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 | | `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 | | `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 | -| `502` | `UPSTREAM_ERROR` | 钱包库、用户资料服务或上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 | -| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单钱包只读库。 | 展示通用失败,记录 `request_id`。 | +| `502` | `UPSTREAM_ERROR` | 榜单 Redis、用户资料、钱包装扮或房间上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 | +| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单 Redis 读模型。 | 展示通用失败,记录 `request_id`。 | ## 客户端展示规则 榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页;上拉加载下一页时用返回的 `total` 判断是否还有更多:`page * page_size < total`。 -`my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”,收礼榜里是“我的收礼排名”;房间榜没有个人排名。未上榜时 `my_rank` 不存在,客户端展示“未上榜”即可,不要自行用 `items` 推断。 +`my_rank` 只表示当前登录用户在当前用户榜单中的排名。送礼榜里是“我的送礼排名”,收礼榜里是“我的收礼排名”,游戏榜里是“我的游戏消费排名”;房间榜没有个人排名。未上榜时 `my_rank` 不存在,客户端展示“未上榜”即可,不要自行用 `items` 推断。 时间展示用 `server_time_ms` 或 `end_at_ms` 标注“更新于”。`today`、`week`、`month` 的统计口径固定为 UTC,展示文案如果需要本地化,只能改时间格式,不能改变请求周期含义。 -榜单数据当前没有单独 IM 推送。用户送礼后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。 +榜单数据当前没有单独 IM 推送。送礼或游戏扣币后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。 diff --git a/pkg/userleaderboard/store.go b/pkg/userleaderboard/store.go index 0ec9adb1..707889e6 100644 --- a/pkg/userleaderboard/store.go +++ b/pkg/userleaderboard/store.go @@ -1,4 +1,4 @@ -// Package userleaderboard owns the Redis read model for App user gift rankings. +// Package userleaderboard owns the Redis read model for App user rankings. package userleaderboard import ( @@ -17,6 +17,7 @@ import ( const ( BoardSent = "sent" BoardReceived = "received" + BoardGame = "game" PeriodToday = "today" PeriodWeek = "week" @@ -33,8 +34,8 @@ const ( var ErrNotConfigured = errors.New("user leaderboard redis is not configured") -// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes. -// The write side consumes wallet committed facts; the read side is used by gateway only. +// Store keeps user leaderboard buckets in Redis zsets plus per-user hashes. +// Gift facts populate sent/received, while successful game debits populate game; gateway only reads this projection. type Store struct { client *redis.Client keyPrefix string @@ -52,6 +53,16 @@ type GiftEvent struct { DirectGift bool } +// GameEvent is the successful game debit fact used by the game-spend leaderboard. +// CoinSpent follows the existing game growth metric and deliberately excludes payout, refund and net-win values. +type GameEvent struct { + AppCode string + EventID string + UserID int64 + CoinSpent int64 + OccurredAtMS int64 +} + // Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time. type Query struct { AppCode string @@ -119,14 +130,6 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro } occurredAt := time.UnixMilli(event.OccurredAtMS).UTC() - keys := []string{s.dedupeKey(event.AppCode, event.EventID)} - args := []any{ - strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10), - strconv.FormatInt(event.GiftValue, 10), - strconv.FormatInt(event.GiftCount, 10), - strconv.FormatInt(event.OccurredAtMS, 10), - } - updates := make([]leaderboardUpdate, 0, 6) for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} { updates = append(updates, @@ -134,17 +137,27 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt), ) } - args = append(args, strconv.Itoa(len(updates))) - for _, update := range updates { - keys = append(keys, update.scoreKey, update.itemKey) - args = append(args, update.member, strconv.FormatInt(int64(update.ttl/time.Second), 10)) + return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.GiftValue, event.GiftCount, event.OccurredAtMS, updates) +} + +// ApplyGameEvent projects one successful game debit into UTC day/week/month game buckets. +// The Redis hash keeps the legacy gift_* field names because the public leaderboard JSON already exposes them; +// for board_type=game, gift_value means game_spend_coin, gift_count stays zero and transaction_count counts debit orders. +func (s *Store) ApplyGameEvent(ctx context.Context, event GameEvent) (bool, error) { + if s == nil || s.client == nil { + return false, ErrNotConfigured + } + event = normalizeGameEvent(event) + if event.AppCode == "" || event.EventID == "" || event.UserID <= 0 || event.CoinSpent <= 0 { + return false, nil } - result, err := applyGiftEventScript.Run(ctx, s.client, keys, args...).Int64() - if err != nil { - return false, err + occurredAt := time.UnixMilli(event.OccurredAtMS).UTC() + updates := make([]leaderboardUpdate, 0, 3) + for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} { + updates = append(updates, s.updateFor(event.AppCode, BoardGame, period, event.UserID, occurredAt)) } - return result == 1, nil + return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.CoinSpent, 0, event.OccurredAtMS, updates) } // List reads a leaderboard page from the current Redis bucket. Empty keys are valid empty rankings. @@ -225,6 +238,8 @@ func NormalizeBoardType(raw string) string { return BoardSent case BoardReceived, "receive", "receiver", "gift_received", "user_received": return BoardReceived + case BoardGame, "games", "gaming": + return BoardGame default: return "" } @@ -308,6 +323,37 @@ func normalizeGiftEvent(event GiftEvent) GiftEvent { return event } +func normalizeGameEvent(event GameEvent) GameEvent { + event.AppCode = appcode.Normalize(event.AppCode) + event.EventID = strings.TrimSpace(event.EventID) + if event.OccurredAtMS <= 0 { + event.OccurredAtMS = time.Now().UTC().UnixMilli() + } + return event +} + +func (s *Store) applyValueEvent(ctx context.Context, app string, eventID string, valueDelta int64, countDelta int64, occurredAtMS int64, updates []leaderboardUpdate) (bool, error) { + keys := []string{s.dedupeKey(app, eventID)} + args := []any{ + strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10), + strconv.FormatInt(valueDelta, 10), + strconv.FormatInt(countDelta, 10), + strconv.FormatInt(occurredAtMS, 10), + strconv.Itoa(len(updates)), + } + for _, update := range updates { + keys = append(keys, update.scoreKey, update.itemKey) + args = append(args, update.member, strconv.FormatInt(int64(update.ttl/time.Second), 10)) + } + + // Dedupe and all period updates execute without interleaving; after a successful script, relay retries cannot double-count. + result, err := applyValueEventScript.Run(ctx, s.client, keys, args...).Int64() + if err != nil { + return false, err + } + return result == 1, nil +} + func normalizeQuery(query Query) Query { query.AppCode = appcode.Normalize(query.AppCode) query.BoardType = NormalizeBoardType(query.BoardType) @@ -385,13 +431,13 @@ func int64FromHash(values map[string]string, key string) int64 { return value } -var applyGiftEventScript = redis.NewScript(` +var applyValueEventScript = redis.NewScript(` if redis.call("EXISTS", KEYS[1]) == 1 then return 0 end redis.call("PSETEX", KEYS[1], ARGV[1], "1") -local gift_value = tonumber(ARGV[2]) -local gift_count = tonumber(ARGV[3]) +local value_delta = tonumber(ARGV[2]) +local count_delta = tonumber(ARGV[3]) local occurred_at_ms = tonumber(ARGV[4]) local updates = tonumber(ARGV[5]) for i = 0, updates - 1 do @@ -399,8 +445,8 @@ for i = 0, updates - 1 do local item_key = KEYS[3 + i * 2] local member = ARGV[6 + i * 2] local ttl_seconds = tonumber(ARGV[7 + i * 2]) - local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value) - redis.call("HINCRBY", item_key, "gift_count", gift_count) + local current_value = redis.call("HINCRBY", item_key, "gift_value", value_delta) + redis.call("HINCRBY", item_key, "gift_count", count_delta) redis.call("HINCRBY", item_key, "transaction_count", 1) local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0") local last_gift_at_ms = previous_last diff --git a/pkg/userleaderboard/store_test.go b/pkg/userleaderboard/store_test.go index cef09ca2..ea3ce104 100644 --- a/pkg/userleaderboard/store_test.go +++ b/pkg/userleaderboard/store_test.go @@ -1,6 +1,9 @@ package userleaderboard import ( + "context" + "fmt" + "os" "testing" "time" ) @@ -34,6 +37,9 @@ func TestNormalizeAliases(t *testing.T) { if NormalizeBoardType("gift_sent") != BoardSent { t.Fatal("gift_sent should normalize to sent") } + if NormalizeBoardType("gaming") != BoardGame { + t.Fatal("gaming should normalize to game") + } if NormalizePeriod("monthly") != PeriodMonth { t.Fatal("monthly should normalize to month") } @@ -49,4 +55,63 @@ func TestStoreKeyPrefixAndBucketAreStable(t *testing.T) { if got := store.itemKey("hyapp_prod", BoardReceived, PeriodToday, time.Date(2026, 6, 26, 0, 0, 0, 0, time.UTC), "10001"); got != "activity:user_leaderboard:hyapp_prod:received:today:20260626:users:10001" { t.Fatalf("item key mismatch: %s", got) } + if got := store.scoreKey("huwaa", BoardGame, PeriodMonth, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)); got != "activity:user_leaderboard:huwaa:game:month:202607:scores" { + t.Fatalf("game score key mismatch: %s", got) + } +} + +func TestApplyGameEventUsesDedicatedRedisBucket(t *testing.T) { + redisAddr := os.Getenv("HYAPP_TEST_REDIS_ADDR") + if redisAddr == "" { + t.Skip("HYAPP_TEST_REDIS_ADDR is not set") + } + ctx := context.Background() + client, err := NewRedisClient(ctx, redisAddr, "", 0) + if err != nil { + t.Fatalf("connect test Redis: %v", err) + } + defer client.Close() + prefix := fmt.Sprintf("test:user_leaderboard:%d", time.Now().UnixNano()) + defer func() { + keys, _, scanErr := client.Scan(ctx, 0, prefix+":*", 100).Result() + if scanErr == nil && len(keys) > 0 { + _ = client.Del(ctx, keys...).Err() + } + }() + store := NewStore(client, prefix) + occurredAt := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + event := GameEvent{ + AppCode: "huwaa", + EventID: "game_level:test_order_1", + UserID: 20002, + CoinSpent: 120, + OccurredAtMS: occurredAt.UnixMilli(), + } + + applied, err := store.ApplyGameEvent(ctx, event) + if err != nil || !applied { + t.Fatalf("first game event should apply: applied=%v err=%v", applied, err) + } + applied, err = store.ApplyGameEvent(ctx, event) + if err != nil || applied { + t.Fatalf("duplicate game event should no-op: applied=%v err=%v", applied, err) + } + page, err := store.List(ctx, Query{ + AppCode: "huwaa", + BoardType: BoardGame, + Period: PeriodToday, + Page: 1, + PageSize: 20, + Now: occurredAt.Add(time.Minute), + }) + if err != nil { + t.Fatalf("list game leaderboard: %v", err) + } + if page.BoardType != BoardGame || page.Total != 1 || len(page.Items) != 1 || page.Items[0].UserID != "20002" || page.Items[0].GiftValue != 120 || page.Items[0].GiftCount != 0 || page.Items[0].TransactionCount != 1 || page.Items[0].LastGiftAtMS != occurredAt.UnixMilli() { + t.Fatalf("game leaderboard item mismatch: %+v", page) + } + sent, err := store.List(ctx, Query{AppCode: "huwaa", BoardType: BoardSent, Period: PeriodToday, Page: 1, PageSize: 20, Now: occurredAt.Add(time.Minute)}) + if err != nil || sent.Total != 0 { + t.Fatalf("game event must not leak into sent board: page=%+v err=%v", sent, err) + } } diff --git a/services/activity-service/internal/app/services.go b/services/activity-service/internal/app/services.go index 176578cd..5fac5b38 100644 --- a/services/activity-service/internal/app/services.go +++ b/services/activity-service/internal/app/services.go @@ -65,7 +65,12 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository, taskSvc := taskservice.New(repository, walletClient) registrationRewardSvc := registrationrewardservice.New(repository, walletClient) sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient) - growthSvc := growthservice.New(repository, walletClient, growthservice.WithNoticeService(messageSvc)) + growthOptions := []growthservice.Option{growthservice.WithNoticeService(messageSvc)} + if userLeaderboardStore != nil { + // 复用游戏等级已接收的 game_spend_coin 事实写榜,避免再建一条会漂移口径的游戏消费链路。 + growthOptions = append(growthOptions, growthservice.WithGameLeaderboard(userLeaderboardStore)) + } + growthSvc := growthservice.New(repository, walletClient, growthOptions...) achievementSvc := achievementservice.New(repository, walletClient) agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient) weeklyStarSvc := weeklystarservice.New(repository, walletClient) diff --git a/services/activity-service/internal/config/config.go b/services/activity-service/internal/config/config.go index 12d8f8ce..975cfc70 100644 --- a/services/activity-service/internal/config/config.go +++ b/services/activity-service/internal/config/config.go @@ -112,9 +112,10 @@ type TaskEventWorkerConfig struct { Enabled bool `yaml:"enabled"` } -// UserLeaderboardWorkerConfig 保存用户送礼榜单 Redis 投影策略。 +// UserLeaderboardWorkerConfig 保存用户榜单 Redis 投影策略。 type UserLeaderboardWorkerConfig struct { - // Enabled 控制是否启动 wallet_outbox 用户榜单消费者;关闭时 gateway 只能读到已有 Redis 聚合。 + // Enabled 控制是否打开榜单 Redis 并启动 wallet_outbox 消费;游戏等级事件也复用该连接写 game 榜。 + // 关闭时 gateway 只能读到已有 Redis 聚合,但等级和任务主链路仍继续处理。 Enabled bool `yaml:"enabled"` // RedisAddr 是 activity 写入用户榜单聚合的 Redis 地址,必须与 gateway 读取地址一致。 RedisAddr string `yaml:"redis_addr"` diff --git a/services/activity-service/internal/service/growth/service.go b/services/activity-service/internal/service/growth/service.go index d122acb7..506cd6e0 100644 --- a/services/activity-service/internal/service/growth/service.go +++ b/services/activity-service/internal/service/growth/service.go @@ -15,6 +15,7 @@ import ( roomeventsv1 "hyapp.local/api/proto/events/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" + "hyapp/pkg/userleaderboard" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/growth" messageservice "hyapp/services/activity-service/internal/service/message" @@ -64,6 +65,12 @@ type WalletClient interface { RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) } +// GameLeaderboard projects the same committed game-spend fact into the short-period user ranking read model. +// It stays optional so activity deployments that intentionally disable the Redis leaderboard keep level processing intact. +type GameLeaderboard interface { + ApplyGameEvent(ctx context.Context, event userleaderboard.GameEvent) (bool, error) +} + // NoticeService 只调用 message service 的公开 helper;growth 不直接写 inbox 表。 type NoticeService interface { CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) @@ -75,12 +82,17 @@ func WithNoticeService(notices NoticeService) Option { return func(service *Service) { service.notices = notices } } +func WithGameLeaderboard(leaderboard GameLeaderboard) Option { + return func(service *Service) { service.gameLeaderboard = leaderboard } +} + // Service 承载 App 查询、事件消费、后台配置和等级奖励补偿。 type Service struct { - repository Repository - wallet WalletClient - notices NoticeService - now func() time.Time + repository Repository + wallet WalletClient + notices NoticeService + gameLeaderboard GameLeaderboard + now func() time.Time } func New(repository Repository, wallet WalletClient, options ...Option) *Service { @@ -310,7 +322,25 @@ func (s *Service) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent if event.OccurredAtMS <= 0 { event.OccurredAtMS = s.now().UnixMilli() } - return s.repository.ConsumeLevelEvent(ctx, event, s.now().UnixMilli()) + result, err := s.repository.ConsumeLevelEvent(ctx, event, s.now().UnixMilli()) + if err != nil { + return domain.EventResult{}, err + } + if s.gameLeaderboard == nil || event.Track != domain.TrackGame || event.MetricType != domain.MetricGameSpendCoin { + return result, nil + } + // MySQL is committed before Redis. Returning a retryable error here makes game-service replay the durable outbox; + // on replay MySQL returns duplicate while Redis event_id dedupe either repairs the missing projection or no-ops safely. + if _, err := s.gameLeaderboard.ApplyGameEvent(ctx, userleaderboard.GameEvent{ + AppCode: appcode.FromContext(ctx), + EventID: event.EventID, + UserID: event.UserID, + CoinSpent: event.ValueDelta, + OccurredAtMS: event.OccurredAtMS, + }); err != nil { + return domain.EventResult{}, fmt.Errorf("project game leaderboard: %w", err) + } + return result, nil } // SetUserLevel 是经理中心的直接等级调整入口。 diff --git a/services/activity-service/internal/service/growth/service_test.go b/services/activity-service/internal/service/growth/service_test.go index 740a5f2e..985c8b91 100644 --- a/services/activity-service/internal/service/growth/service_test.go +++ b/services/activity-service/internal/service/growth/service_test.go @@ -2,6 +2,7 @@ package growth_test import ( "context" + "errors" "fmt" "testing" "time" @@ -11,6 +12,7 @@ import ( activityevents "hyapp.local/api/proto/events/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" + "hyapp/pkg/userleaderboard" domain "hyapp/services/activity-service/internal/domain/growth" growthservice "hyapp/services/activity-service/internal/service/growth" messageservice "hyapp/services/activity-service/internal/service/message" @@ -89,6 +91,42 @@ func TestGrowthLevelEventRewardFlow(t *testing.T) { } } +func TestGameSpendLeaderboardProjectionRetriesAfterRedisFailure(t *testing.T) { + repository := mysqltest.NewRepository(t) + projector := &fakeGameLeaderboard{failuresRemaining: 1} + svc := growthservice.New(repository, &fakeGrowthWallet{}, growthservice.WithGameLeaderboard(projector)) + svc.SetClock(fixedGrowthNow) + ctx := appcode.WithContext(context.Background(), "huwaa") + seedGrowthRules(t, ctx, svc, domain.TrackGame) + event := domain.ValueEvent{ + EventID: "game_level:order_projection_1", + SourceEventID: "order_projection_1", + SourceService: "game-service", + SourceEventType: "GameCoinDebited", + UserID: 20002, + Track: domain.TrackGame, + MetricType: domain.MetricGameSpendCoin, + ValueDelta: 120, + OccurredAtMS: fixedGrowthNow().UnixMilli(), + DimensionsJSON: `{"game_id":"dice"}`, + } + + if _, err := svc.ConsumeLevelEvent(ctx, event); err == nil { + t.Fatal("Redis projection failure must keep the durable game outbox retryable") + } + retry, err := svc.ConsumeLevelEvent(ctx, event) + if err != nil || retry.Status != domain.EventStatusDuplicate { + t.Fatalf("retry should repair Redis after MySQL duplicate: result=%+v err=%v", retry, err) + } + if len(projector.events) != 2 { + t.Fatalf("projection call count = %d, want 2", len(projector.events)) + } + projected := projector.events[1] + if projected.AppCode != "huwaa" || projected.EventID != event.EventID || projected.UserID != event.UserID || projected.CoinSpent != event.ValueDelta || projected.OccurredAtMS != event.OccurredAtMS { + t.Fatalf("projected game event mismatch: %+v", projected) + } +} + func TestTemporaryGrowthLevelOverlayExpiresAndRevokesAllWalletGrants(t *testing.T) { repository := mysqltest.NewRepository(t) wallet := &fakeGrowthWallet{} @@ -987,6 +1025,20 @@ func newGrowthService(t *testing.T) (*growthservice.Service, *fakeGrowthWallet) return svc, wallet } +type fakeGameLeaderboard struct { + events []userleaderboard.GameEvent + failuresRemaining int +} + +func (f *fakeGameLeaderboard) ApplyGameEvent(_ context.Context, event userleaderboard.GameEvent) (bool, error) { + f.events = append(f.events, event) + if f.failuresRemaining > 0 { + f.failuresRemaining-- + return false, errors.New("redis unavailable") + } + return true, nil +} + func seedGrowthRules(t *testing.T, ctx context.Context, svc *growthservice.Service, track string) { t.Helper() if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{ diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go index 3c81d6b4..d41cb360 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go @@ -20,6 +20,7 @@ import ( const ( leaderboardTypeSent = userleaderboard.BoardSent leaderboardTypeReceived = userleaderboard.BoardReceived + leaderboardTypeGame = userleaderboard.BoardGame leaderboardTypeRoom = "room" leaderboardPeriodToday = userleaderboard.PeriodToday diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go index 51f9eac2..39fffff5 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go @@ -45,6 +45,9 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) { if normalizeUserLeaderboardType("rooms") != leaderboardTypeRoom { t.Fatal("rooms should normalize to room") } + if normalizeUserLeaderboardType("gaming") != leaderboardTypeGame { + t.Fatal("gaming should normalize to game") + } if normalizeUserLeaderboardPeriod("monthly") != leaderboardPeriodMonth { t.Fatal("monthly should normalize to month") } @@ -171,6 +174,58 @@ func TestListUserLeaderboardsReadsRedisStore(t *testing.T) { } } +func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) { + store := &fakeUserLeaderboardStore{ + page: userleaderboard.Page{ + Items: []userleaderboard.Entry{{ + Rank: 1, + UserID: "20002", + GiftValue: 1200, + TransactionCount: 4, + LastGiftAtMS: 1784095200000, + }}, + Total: 1, + BoardType: leaderboardTypeGame, + Period: leaderboardPeriodToday, + StartAtMS: 1784073600000, + EndAtMS: 1784095200000, + ServerTimeMS: 1784095200000, + }, + rank: userleaderboard.Entry{ + Rank: 8, + UserID: "10001", + GiftValue: 180, + TransactionCount: 2, + LastGiftAtMS: 1784095000000, + }, + rankFound: true, + } + handler := &Handler{userLeaderboard: store} + request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/user-leaderboards?board_type=gaming&period=today&page=1&page_size=20", nil) + ctx := appcode.WithContext(request.Context(), "huwaa") + ctx = auth.WithUserID(ctx, 10001) + recorder := httptest.NewRecorder() + + handler.listUserLeaderboards(recorder, request.WithContext(ctx)) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } + if store.listCalls != 1 || store.rankCalls != 1 || store.listQuery.AppCode != "huwaa" || store.listQuery.BoardType != leaderboardTypeGame || store.rankQuery.BoardType != leaderboardTypeGame { + t.Fatalf("game query should use its own Redis bucket: list=%+v rank=%+v", store.listQuery, store.rankQuery) + } + var envelope struct { + Code string `json:"code"` + Data userLeaderboardResponse `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if envelope.Code != "OK" || envelope.Data.BoardType != leaderboardTypeGame || len(envelope.Data.Items) != 1 || envelope.Data.Items[0].GiftValue != 1200 || envelope.Data.MyRank == nil || envelope.Data.MyRank.Rank != 8 { + t.Fatalf("game leaderboard response mismatch: %+v", envelope.Data) + } +} + type fakeUserLeaderboardStore struct { listCalls int rankCalls int diff --git a/services/gateway-service/internal/transport/http/handler.go b/services/gateway-service/internal/transport/http/handler.go index 4e512083..4ac7874d 100644 --- a/services/gateway-service/internal/transport/http/handler.go +++ b/services/gateway-service/internal/transport/http/handler.go @@ -367,7 +367,7 @@ func (h *Handler) SetStatisticsClient(statisticsClient client.StatisticsClient) h.statisticsClient = statisticsClient } -// SetUserLeaderboardStore 注入 App 用户送礼榜 Redis 读模型;gateway 只读聚合结果。 +// SetUserLeaderboardStore 注入 App 用户榜 Redis 读模型;gateway 只读 activity-service 的聚合结果。 func (h *Handler) SetUserLeaderboardStore(store *userleaderboard.Store) { h.userLeaderboard = store }