游戏排行榜
This commit is contained in:
parent
fdcd29126d
commit
e6767899c3
@ -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 来本地累加全局榜单。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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 的公开 helper;growth 不直接写 inbox 表。
|
// NoticeService 只调用 message service 的公开 helper;growth 不直接写 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,12 +82,17 @@ 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
|
||||||
now func() time.Time
|
gameLeaderboard GameLeaderboard
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, wallet WalletClient, options ...Option) *Service {
|
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 {
|
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 是经理中心的直接等级调整入口。
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user