游戏排行榜
This commit is contained in:
parent
fdcd29126d
commit
e6767899c3
@ -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 <access_token>
|
||||
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 来本地累加全局榜单。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 是经理中心的直接等级调整入口。
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
const (
|
||||
leaderboardTypeSent = userleaderboard.BoardSent
|
||||
leaderboardTypeReceived = userleaderboard.BoardReceived
|
||||
leaderboardTypeGame = userleaderboard.BoardGame
|
||||
leaderboardTypeRoom = "room"
|
||||
|
||||
leaderboardPeriodToday = userleaderboard.PeriodToday
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user