游戏排行榜

This commit is contained in:
zhx 2026-07-16 10:04:47 +08:00
parent fdcd29126d
commit e6767899c3
10 changed files with 315 additions and 49 deletions

View File

@ -1,6 +1,6 @@
# 用户排行榜 Flutter 对接 # 用户排行榜 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`。 | | `period` | string | 否 | `today` | 统计周期。`today``week``month`。 |
| `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 | | `page` | int32 | 否 | `1` | 页码,从 1 开始。必须大于 0。 |
| `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0服务端最大返回 `100`。 | | `page_size` | int32 | 否 | `20` | 每页数量。必须大于 0服务端最大返回 `100`。 |
@ -60,6 +60,7 @@ Query 参数:
| --- | --- | --- | | --- | --- | --- |
| `sent` | 空字符串、`send``sender``gift_sent``user_sent` | 按送礼用户聚合,展示用户送礼贡献。 | | `sent` | 空字符串、`send``sender``gift_sent``user_sent` | 按送礼用户聚合,展示用户送礼贡献。 |
| `received` | `receive``receiver``gift_received``user_received` | 按收礼用户聚合,展示用户收礼魅力。 | | `received` | `receive``receiver``gift_received``user_received` | 按收礼用户聚合,展示用户收礼魅力。 |
| `game` | `games``gaming` | 按用户聚合成功游戏扣币额;不累计派奖、退款或净输赢。 |
| `room` | `rooms``room_gift``room_gifts` | 按房间聚合,展示房间礼物流水。 | | `room` | `rooms``room_gift``room_gifts` | 按房间聚合,展示房间礼物流水。 |
`period` 兼容别名: `period` 兼容别名:
@ -70,7 +71,7 @@ Query 参数:
| `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 | | `week` | `weekly` | UTC 本周周一 00:00:00 到当前服务端时间。 |
| `month` | `monthly` | UTC 本月 1 日 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` 表示收礼用户。 收礼榜按 `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 <access_token>
X-App-Code: huwaa
```
游戏榜按成功扣币订单的 `user_id` 聚合 `game_spend_coin`。只有 `op_type=debit` 且扣币成功、金额大于 0 的订单进入榜单;派奖、退款和净输赢不会改变游戏榜积分。返回项中的 `gift_value` 是兼容字段,游戏榜场景表示累计游戏扣币额。
### 房间榜 ### 房间榜
```http ```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 | 当前榜单和周期下的总上榜主体数。 | | `total` | int64 | 当前榜单和周期下的总上榜主体数。 |
| `page` | int32 | 服务端解析后的页码。 | | `page` | int32 | 服务端解析后的页码。 |
| `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 | | `page_size` | int32 | 服务端实际使用的每页数量;请求超过 100 时返回 100。 |
| `board_type` | string | 服务端归一化后的榜单类型:`sent``received``room`。 | | `board_type` | string | 服务端归一化后的榜单类型:`sent``received``game`、`room`。 |
| `period` | string | 服务端归一化后的统计周期:`today``week``month`。 | | `period` | string | 服务端归一化后的统计周期:`today``week``month`。 |
| `start_at_ms` | int64 | 本次统计窗口开始时间UTC。 | | `start_at_ms` | int64 | 本次统计窗口开始时间UTC。 |
| `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 | | `end_at_ms` | int64 | 本次统计窗口结束时间,等于请求时的当前服务端时间。 |
| `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 | | `server_time_ms` | int64 | 服务端当前时间,可用于前端展示“更新时间”。 |
| `my_rank` | object? | 当前登录用户在送礼榜或收礼榜中的排名;未上榜或 `board_type=room` 时不返回。 | | `my_rank` | object? | 当前登录用户在送礼榜、收礼榜或游戏榜中的排名;未上榜或 `board_type=room` 时不返回。 |
`items[]` / `my_rank` 字段: `items[]` / `my_rank` 字段:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `rank` | int64 | 排名,从 1 开始。 | | `rank` | int64 | 排名,从 1 开始。 |
| `user_id` | string | 用户榜单主体 ID`sent``received` 才有。 | | `user_id` | string | 用户榜单主体 ID`sent``received``game` 才有。 |
| `room_id` | string | 房间榜单主体 ID`room` 才有。 | | `room_id` | string | 房间榜单主体 ID`room` 才有。 |
| `gift_value` | int64 | 统计窗口内礼物价值总和,用于排名和主数值展示。 | | `gift_value` | int64 | 排名累计值。`sent/received/room` 表示礼物价值;`game` 表示成功游戏扣币额。 |
| `gift_count` | int64 | 统计窗口内礼物数量总和。 | | `gift_count` | int64 | 礼物榜的礼物数量总和;`game` 固定为 0。 |
| `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 | | `transaction_count` | int64 | 用户榜的成功事实数;`game` 表示成功扣币订单数。 |
| `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 | | `last_gift_at_ms` | int64 | 最近一次计榜事实时间;`game` 表示最近一次成功游戏扣币时间。字段名为兼容既有协议保留。 |
| `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像本地未注入用户资料 client 时至少返回 `user_id`。 | | `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像本地未注入用户资料 client 时至少返回 `user_id`。 |
| `room` | object? | 房间基础投影;房间榜返回 `room_id``room_short_id``title``cover_url``room_avatar`。 | | `room` | object? | 房间基础投影;房间榜返回 `room_id``room_short_id``title``cover_url``room_avatar`。 |
@ -415,7 +426,7 @@ class LeaderboardRoom {
请求封装示例: 请求封装示例:
```dart ```dart
enum LeaderboardType { sent, received, room } enum LeaderboardType { sent, received, game, room }
enum LeaderboardPeriod { today, week, month } enum LeaderboardPeriod { today, week, month }
extension on LeaderboardType { extension on LeaderboardType {
@ -493,15 +504,15 @@ class ApiException implements Exception {
| `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 | | `401` | `SESSION_REVOKED` | 会话已被服务端撤销。 | 清理本地登录态并重新登录。 |
| `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 | | `403` | `PROFILE_REQUIRED` | 当前 token 对应用户未完成资料。 | 跳资料补全流程。 |
| `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 | | `405` | `INVALID_ARGUMENT` | 使用了非 GET 方法。 | 修正客户端请求方法。 |
| `502` | `UPSTREAM_ERROR` | 钱包库、用户资料服务或上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 | | `502` | `UPSTREAM_ERROR` | 榜单 Redis、用户资料、钱包装扮或房间上游依赖异常。 | 展示通用失败,可保留旧榜单并允许下拉重试;记录 `request_id`。 |
| `503` | `UPSTREAM_ERROR` | gateway 未配置榜单钱包只读库。 | 展示通用失败,记录 `request_id`。 | | `503` | `UPSTREAM_ERROR` | gateway 未配置榜单 Redis 读模型。 | 展示通用失败,记录 `request_id`。 |
## 客户端展示规则 ## 客户端展示规则
榜单页建议用 `board_type + period + page + page_size` 做缓存 key。用户切换榜单类型或周期时重新请求第一页上拉加载下一页时用返回的 `total` 判断是否还有更多:`page * page_size < total` 榜单页建议用 `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展示文案如果需要本地化只能改时间格式不能改变请求周期含义。 时间展示用 `server_time_ms``end_at_ms` 标注“更新于”。`today``week``month` 的统计口径固定为 UTC展示文案如果需要本地化只能改时间格式不能改变请求周期含义。
榜单数据当前没有单独 IM 推送。用户送礼后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。 榜单数据当前没有单独 IM 推送。送礼或游戏扣币后如果当前页面正在展示对应榜单,可以延迟刷新第一页或由用户手动下拉刷新;不要依赖房间公屏 IM 来本地累加全局榜单。

View File

@ -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 package userleaderboard
import ( import (
@ -17,6 +17,7 @@ import (
const ( const (
BoardSent = "sent" BoardSent = "sent"
BoardReceived = "received" BoardReceived = "received"
BoardGame = "game"
PeriodToday = "today" PeriodToday = "today"
PeriodWeek = "week" PeriodWeek = "week"
@ -33,8 +34,8 @@ const (
var ErrNotConfigured = errors.New("user leaderboard redis is not configured") var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes. // Store keeps user leaderboard buckets in Redis zsets plus per-user hashes.
// The write side consumes wallet committed facts; the read side is used by gateway only. // Gift facts populate sent/received, while successful game debits populate game; gateway only reads this projection.
type Store struct { type Store struct {
client *redis.Client client *redis.Client
keyPrefix string keyPrefix string
@ -52,6 +53,16 @@ type GiftEvent struct {
DirectGift bool 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. // Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time.
type Query struct { type Query struct {
AppCode string AppCode string
@ -119,14 +130,6 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
} }
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC() 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) updates := make([]leaderboardUpdate, 0, 6)
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} { for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
updates = append(updates, 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), s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
) )
} }
args = append(args, strconv.Itoa(len(updates))) return s.applyValueEvent(ctx, event.AppCode, event.EventID, event.GiftValue, event.GiftCount, event.OccurredAtMS, 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)) // 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() occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
if err != nil { updates := make([]leaderboardUpdate, 0, 3)
return false, err 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. // 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 return BoardSent
case BoardReceived, "receive", "receiver", "gift_received", "user_received": case BoardReceived, "receive", "receiver", "gift_received", "user_received":
return BoardReceived return BoardReceived
case BoardGame, "games", "gaming":
return BoardGame
default: default:
return "" return ""
} }
@ -308,6 +323,37 @@ func normalizeGiftEvent(event GiftEvent) GiftEvent {
return event 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 { func normalizeQuery(query Query) Query {
query.AppCode = appcode.Normalize(query.AppCode) query.AppCode = appcode.Normalize(query.AppCode)
query.BoardType = NormalizeBoardType(query.BoardType) query.BoardType = NormalizeBoardType(query.BoardType)
@ -385,13 +431,13 @@ func int64FromHash(values map[string]string, key string) int64 {
return value return value
} }
var applyGiftEventScript = redis.NewScript(` var applyValueEventScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then if redis.call("EXISTS", KEYS[1]) == 1 then
return 0 return 0
end end
redis.call("PSETEX", KEYS[1], ARGV[1], "1") redis.call("PSETEX", KEYS[1], ARGV[1], "1")
local gift_value = tonumber(ARGV[2]) local value_delta = tonumber(ARGV[2])
local gift_count = tonumber(ARGV[3]) local count_delta = tonumber(ARGV[3])
local occurred_at_ms = tonumber(ARGV[4]) local occurred_at_ms = tonumber(ARGV[4])
local updates = tonumber(ARGV[5]) local updates = tonumber(ARGV[5])
for i = 0, updates - 1 do for i = 0, updates - 1 do
@ -399,8 +445,8 @@ for i = 0, updates - 1 do
local item_key = KEYS[3 + i * 2] local item_key = KEYS[3 + i * 2]
local member = ARGV[6 + i * 2] local member = ARGV[6 + i * 2]
local ttl_seconds = tonumber(ARGV[7 + i * 2]) local ttl_seconds = tonumber(ARGV[7 + i * 2])
local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value) local current_value = redis.call("HINCRBY", item_key, "gift_value", value_delta)
redis.call("HINCRBY", item_key, "gift_count", gift_count) redis.call("HINCRBY", item_key, "gift_count", count_delta)
redis.call("HINCRBY", item_key, "transaction_count", 1) redis.call("HINCRBY", item_key, "transaction_count", 1)
local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0") local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0")
local last_gift_at_ms = previous_last local last_gift_at_ms = previous_last

View File

@ -1,6 +1,9 @@
package userleaderboard package userleaderboard
import ( import (
"context"
"fmt"
"os"
"testing" "testing"
"time" "time"
) )
@ -34,6 +37,9 @@ func TestNormalizeAliases(t *testing.T) {
if NormalizeBoardType("gift_sent") != BoardSent { if NormalizeBoardType("gift_sent") != BoardSent {
t.Fatal("gift_sent should normalize to sent") t.Fatal("gift_sent should normalize to sent")
} }
if NormalizeBoardType("gaming") != BoardGame {
t.Fatal("gaming should normalize to game")
}
if NormalizePeriod("monthly") != PeriodMonth { if NormalizePeriod("monthly") != PeriodMonth {
t.Fatal("monthly should normalize to month") 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" { 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) 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)
}
} }

View File

@ -65,7 +65,12 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
taskSvc := taskservice.New(repository, walletClient) taskSvc := taskservice.New(repository, walletClient)
registrationRewardSvc := registrationrewardservice.New(repository, walletClient) registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
sevenDayCheckInSvc := sevendaycheckinservice.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) achievementSvc := achievementservice.New(repository, walletClient)
agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient) agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient)
weeklyStarSvc := weeklystarservice.New(repository, walletClient) weeklyStarSvc := weeklystarservice.New(repository, walletClient)

View File

@ -112,9 +112,10 @@ type TaskEventWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
} }
// UserLeaderboardWorkerConfig 保存用户送礼榜单 Redis 投影策略。 // UserLeaderboardWorkerConfig 保存用户榜单 Redis 投影策略。
type UserLeaderboardWorkerConfig struct { type UserLeaderboardWorkerConfig struct {
// Enabled 控制是否启动 wallet_outbox 用户榜单消费者;关闭时 gateway 只能读到已有 Redis 聚合。 // Enabled 控制是否打开榜单 Redis 并启动 wallet_outbox 消费;游戏等级事件也复用该连接写 game 榜。
// 关闭时 gateway 只能读到已有 Redis 聚合,但等级和任务主链路仍继续处理。
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
// RedisAddr 是 activity 写入用户榜单聚合的 Redis 地址,必须与 gateway 读取地址一致。 // RedisAddr 是 activity 写入用户榜单聚合的 Redis 地址,必须与 gateway 读取地址一致。
RedisAddr string `yaml:"redis_addr"` RedisAddr string `yaml:"redis_addr"`

View File

@ -15,6 +15,7 @@ import (
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/userleaderboard"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/growth" domain "hyapp/services/activity-service/internal/domain/growth"
messageservice "hyapp/services/activity-service/internal/service/message" 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) 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 的公开 helpergrowth 不直接写 inbox 表。 // NoticeService 只调用 message service 的公开 helpergrowth 不直接写 inbox 表。
type NoticeService interface { type NoticeService interface {
CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
@ -75,11 +82,16 @@ func WithNoticeService(notices NoticeService) Option {
return func(service *Service) { service.notices = notices } return func(service *Service) { service.notices = notices }
} }
func WithGameLeaderboard(leaderboard GameLeaderboard) Option {
return func(service *Service) { service.gameLeaderboard = leaderboard }
}
// Service 承载 App 查询、事件消费、后台配置和等级奖励补偿。 // Service 承载 App 查询、事件消费、后台配置和等级奖励补偿。
type Service struct { type Service struct {
repository Repository repository Repository
wallet WalletClient wallet WalletClient
notices NoticeService notices NoticeService
gameLeaderboard GameLeaderboard
now func() time.Time now func() time.Time
} }
@ -310,7 +322,25 @@ func (s *Service) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent
if event.OccurredAtMS <= 0 { if event.OccurredAtMS <= 0 {
event.OccurredAtMS = s.now().UnixMilli() 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 是经理中心的直接等级调整入口。 // SetUserLevel 是经理中心的直接等级调整入口。

View File

@ -2,6 +2,7 @@ package growth_test
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"testing" "testing"
"time" "time"
@ -11,6 +12,7 @@ import (
activityevents "hyapp.local/api/proto/events/room/v1" activityevents "hyapp.local/api/proto/events/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/userleaderboard"
domain "hyapp/services/activity-service/internal/domain/growth" domain "hyapp/services/activity-service/internal/domain/growth"
growthservice "hyapp/services/activity-service/internal/service/growth" growthservice "hyapp/services/activity-service/internal/service/growth"
messageservice "hyapp/services/activity-service/internal/service/message" 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) { func TestTemporaryGrowthLevelOverlayExpiresAndRevokesAllWalletGrants(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{} wallet := &fakeGrowthWallet{}
@ -987,6 +1025,20 @@ func newGrowthService(t *testing.T) (*growthservice.Service, *fakeGrowthWallet)
return svc, wallet 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) { func seedGrowthRules(t *testing.T, ctx context.Context, svc *growthservice.Service, track string) {
t.Helper() t.Helper()
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{ if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{

View File

@ -20,6 +20,7 @@ import (
const ( const (
leaderboardTypeSent = userleaderboard.BoardSent leaderboardTypeSent = userleaderboard.BoardSent
leaderboardTypeReceived = userleaderboard.BoardReceived leaderboardTypeReceived = userleaderboard.BoardReceived
leaderboardTypeGame = userleaderboard.BoardGame
leaderboardTypeRoom = "room" leaderboardTypeRoom = "room"
leaderboardPeriodToday = userleaderboard.PeriodToday leaderboardPeriodToday = userleaderboard.PeriodToday

View File

@ -45,6 +45,9 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) {
if normalizeUserLeaderboardType("rooms") != leaderboardTypeRoom { if normalizeUserLeaderboardType("rooms") != leaderboardTypeRoom {
t.Fatal("rooms should normalize to room") t.Fatal("rooms should normalize to room")
} }
if normalizeUserLeaderboardType("gaming") != leaderboardTypeGame {
t.Fatal("gaming should normalize to game")
}
if normalizeUserLeaderboardPeriod("monthly") != leaderboardPeriodMonth { if normalizeUserLeaderboardPeriod("monthly") != leaderboardPeriodMonth {
t.Fatal("monthly should normalize to month") 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 { type fakeUserLeaderboardStore struct {
listCalls int listCalls int
rankCalls int rankCalls int

View File

@ -367,7 +367,7 @@ func (h *Handler) SetStatisticsClient(statisticsClient client.StatisticsClient)
h.statisticsClient = statisticsClient h.statisticsClient = statisticsClient
} }
// SetUserLeaderboardStore 注入 App 用户送礼榜 Redis 读模型gateway 只读聚合结果。 // SetUserLeaderboardStore 注入 App 用户榜 Redis 读模型gateway 只读 activity-service 的聚合结果。
func (h *Handler) SetUserLeaderboardStore(store *userleaderboard.Store) { func (h *Handler) SetUserLeaderboardStore(store *userleaderboard.Store) {
h.userLeaderboard = store h.userLeaderboard = store
} }