三方游戏
This commit is contained in:
parent
d4b2bdb789
commit
e818542d93
466
docs/猜拳对战游戏方案.md
Normal file
466
docs/猜拳对战游戏方案.md
Normal file
@ -0,0 +1,466 @@
|
||||
# 猜拳对战游戏方案
|
||||
|
||||
本文定义 `game-service` 内新增猜拳对战模块的产品规则、服务边界、数据模型、接口和 IM 事件。当前方案基于仓库现状:App HTTP 入口在 `gateway-service`,游戏事实 owner 在 `game-service`,客户端长连接和实时投递使用腾讯云 IM,服务端私有/群消息投递能力复用 `notice-service` 和 `pkg/tencentim`。
|
||||
|
||||
## 结论
|
||||
|
||||
- 猜拳对战状态归 `game-service` 持有,不放到 `room-service`,也不让 gateway 承载业务状态。
|
||||
- 客户端出拳必须走 HTTP 到 gateway,再转 `game-service` gRPC。IM 只做服务端事件下发,客户端不能通过 IM 直接提交手势。
|
||||
- 1v1 对战使用腾讯云 IM C2C 自定义消息投递给双方;不为每局创建 IM 群,避免大量临时群创建和清理成本。
|
||||
- 服务端所有时间使用 UTC epoch milliseconds,IM payload 带 `server_time_ms`、`deadline_ms` 和 `match_version`,客户端按服务端时间展示倒计时。
|
||||
- 首版按免费对战设计,`field.stake_coin` 预留为 0。若产品后续要求金币场,再用现有 wallet `ApplyGameCoinChange` 结算投注和派奖。
|
||||
|
||||
## 游戏规则
|
||||
|
||||
### 大厅和场次
|
||||
|
||||
大厅展示三个场:
|
||||
|
||||
| field_code | 名称 | 匹配池 | stake_coin |
|
||||
|---|---|---|---|
|
||||
| rookie | 新手场 | 新手独立池 | 0 |
|
||||
| intermediate | 中级场 | 中级独立池 | 0 |
|
||||
| advanced | 高级场 | 高级独立池 | 0 |
|
||||
|
||||
字段预留:
|
||||
|
||||
- `min_balance`:付费场入场余额要求。
|
||||
- `stake_coin`:每局投注额。首版为 0。
|
||||
- `choice_timeout_ms`:每小局选手势时间,建议 8000。
|
||||
- `match_start_countdown_ms`:匹配成功后开局倒计时,固定 3000。
|
||||
- `reveal_countdown_ms`:双方都选好后展示 3、2、1,固定 3000。
|
||||
|
||||
### 对战流程
|
||||
|
||||
1. 用户进入大厅,选择新手场、中级场或高级场。
|
||||
2. 点击进入匹配,服务端把用户放入对应 `field_code` 的等待队列。
|
||||
3. 找到同场等待用户后创建 `match`,状态为 `matched`,设置 `start_at_ms = now + 3000`。
|
||||
4. 开局后进入第 1 小局,双方在 `choice_deadline_ms` 前提交 `rock/paper/scissor`。
|
||||
5. 双方都提交后,服务端设置 `reveal_at_ms = now + 3000`,通过 IM 通知客户端展示 `3,2,1`。
|
||||
6. 到 `reveal_at_ms` 后,服务端公开双方手势,判定本小局胜负。
|
||||
7. 先拿到 2 个胜场的用户获胜。
|
||||
8. 平局不计胜场,继续下一小局;为防止无限平局,最多 5 小局,5 小局后仍未分出 2 胜则整场平局。
|
||||
|
||||
### 超时规则
|
||||
|
||||
- 一方未在 `choice_deadline_ms` 前出拳,另一方已出拳:未出拳方本小局失败。
|
||||
- 双方都未出拳:本小局平局。
|
||||
- 用户断线不直接判输,以提交超时为准;客户端重连后调用查询接口恢复当前状态。
|
||||
|
||||
## 服务端边界
|
||||
|
||||
### game-service
|
||||
|
||||
新增模块建议:
|
||||
|
||||
- `services/game-service/internal/domain/rps`:状态、规则、胜负计算。
|
||||
- `services/game-service/internal/service/rps`:匹配、出拳、结算、超时推进。
|
||||
- `services/game-service/internal/storage/mysql/rps_repository.go`:MySQL 持久化和行锁。
|
||||
- `services/game-service/internal/transport/grpc`:新增猜拳 RPC 适配。
|
||||
|
||||
职责:
|
||||
|
||||
- 持有匹配队列、对战、轮次、出拳、结算事实。
|
||||
- 写入实时事件 outbox。
|
||||
- 提供查询接口,供客户端丢 IM 或重连后恢复状态。
|
||||
|
||||
### gateway-service
|
||||
|
||||
职责:
|
||||
|
||||
- 提供 `/api/v1/games/rps...` HTTP 接口。
|
||||
- 从 JWT 上下文拿 `user_id`,不信任客户端传入的玩家 ID。
|
||||
- 转换 HTTP 参数到 `game-service` gRPC。
|
||||
|
||||
### notice-service / Tencent IM
|
||||
|
||||
职责:
|
||||
|
||||
- 消费 game-service 的猜拳实时事件 outbox。
|
||||
- 使用 `pkg/tencentim.PublishUserCustomMessage` 给双方发 C2C 自定义消息。
|
||||
- 记录投递状态和重试,客户端按 `event_id` 去重。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### game_rps_fields
|
||||
|
||||
场次配置。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `app_code`
|
||||
- `field_code`
|
||||
- `field_name`
|
||||
- `status`
|
||||
- `sort_order`
|
||||
- `stake_coin`
|
||||
- `min_balance`
|
||||
- `choice_timeout_ms`
|
||||
- `match_start_countdown_ms`
|
||||
- `reveal_countdown_ms`
|
||||
- `created_at_ms`
|
||||
- `updated_at_ms`
|
||||
|
||||
主键:`(app_code, field_code)`
|
||||
|
||||
### game_rps_match_queue
|
||||
|
||||
匹配等待队列。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `app_code`
|
||||
- `queue_id`
|
||||
- `field_code`
|
||||
- `user_id`
|
||||
- `status`: `waiting/matched/cancelled/expired`
|
||||
- `match_id`
|
||||
- `created_at_ms`
|
||||
- `updated_at_ms`
|
||||
|
||||
索引:
|
||||
|
||||
- `idx_rps_queue_match(app_code, field_code, status, created_at_ms, queue_id)`
|
||||
- `idx_rps_queue_user(app_code, user_id, status, created_at_ms)`
|
||||
|
||||
### game_rps_matches
|
||||
|
||||
对战主表。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `app_code`
|
||||
- `match_id`
|
||||
- `field_code`
|
||||
- `status`: `matched/countdown/playing/finished/cancelled`
|
||||
- `player1_user_id`
|
||||
- `player2_user_id`
|
||||
- `winner_user_id`
|
||||
- `player1_score`
|
||||
- `player2_score`
|
||||
- `round_no`
|
||||
- `match_version`
|
||||
- `start_at_ms`
|
||||
- `finished_at_ms`
|
||||
- `created_at_ms`
|
||||
- `updated_at_ms`
|
||||
|
||||
索引:
|
||||
|
||||
- `idx_rps_match_player(app_code, player1_user_id, status, updated_at_ms)`
|
||||
- `idx_rps_match_player2(app_code, player2_user_id, status, updated_at_ms)`
|
||||
- `idx_rps_match_timeout(app_code, status, updated_at_ms)`
|
||||
|
||||
### game_rps_rounds
|
||||
|
||||
小局表。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `app_code`
|
||||
- `match_id`
|
||||
- `round_no`
|
||||
- `status`: `picking/reveal_countdown/revealed`
|
||||
- `choice_deadline_ms`
|
||||
- `reveal_at_ms`
|
||||
- `player1_choice`
|
||||
- `player2_choice`
|
||||
- `winner_user_id`
|
||||
- `result`: `player1_win/player2_win/draw`
|
||||
- `created_at_ms`
|
||||
- `updated_at_ms`
|
||||
|
||||
主键:`(app_code, match_id, round_no)`
|
||||
|
||||
### game_rps_realtime_outbox
|
||||
|
||||
实时 IM 事件 outbox。不要复用现有 `game_outbox`,因为现有 `gamemq.GameOutboxMessage` 是订单/统计事件,要求 `order_id`、`op_type`、`coin_amount > 0`,不适合匹配、倒计时和揭晓这种非订单事件。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `app_code`
|
||||
- `event_id`
|
||||
- `event_type`
|
||||
- `match_id`
|
||||
- `target_user_id`
|
||||
- `payload_json`
|
||||
- `status`: `pending/running/delivered/retryable/failed`
|
||||
- `worker_id`
|
||||
- `lock_until_ms`
|
||||
- `retry_count`
|
||||
- `next_retry_at_ms`
|
||||
- `last_error`
|
||||
- `created_at_ms`
|
||||
- `updated_at_ms`
|
||||
|
||||
索引:
|
||||
|
||||
- `idx_rps_realtime_claim(app_code, status, next_retry_at_ms, created_at_ms, event_id)`
|
||||
- `idx_rps_realtime_target(app_code, target_user_id, created_at_ms)`
|
||||
|
||||
## 内部 gRPC
|
||||
|
||||
在 `api/proto/game/v1/game.proto` 的 `GameAppService` 增加:
|
||||
|
||||
- `ListRPSFields`
|
||||
- `JoinRPSMatch`
|
||||
- `CancelRPSMatch`
|
||||
- `GetRPSMatch`
|
||||
- `SubmitRPSChoice`
|
||||
|
||||
核心消息:
|
||||
|
||||
- `RPSField`
|
||||
- `RPSMatch`
|
||||
- `RPSPlayer`
|
||||
- `RPSRound`
|
||||
- `RPSChoice`
|
||||
|
||||
`RequestMeta.request_id` 继续只做链路追踪,不做幂等键。出拳幂等靠 `(app_code, match_id, round_no, user_id)` 唯一事实保证。
|
||||
|
||||
## HTTP 接口
|
||||
|
||||
### 1. 大厅场次
|
||||
|
||||
地址:`GET /api/v1/games/rps/fields`
|
||||
|
||||
参数:
|
||||
|
||||
- 无
|
||||
|
||||
返回值:
|
||||
|
||||
- `fields`: 场次列表
|
||||
- `server_time_ms`
|
||||
|
||||
相关 IM:
|
||||
|
||||
- 无
|
||||
|
||||
### 2. 进入匹配
|
||||
|
||||
地址:`POST /api/v1/games/rps/fields/{field_code}/match`
|
||||
|
||||
参数:
|
||||
|
||||
- `field_code`: `rookie/intermediate/advanced`
|
||||
|
||||
返回值:
|
||||
|
||||
- `status`: `waiting/matched`
|
||||
- `queue_id`
|
||||
- `match_id`
|
||||
- `start_at_ms`
|
||||
- `server_time_ms`
|
||||
|
||||
相关 IM:
|
||||
|
||||
- `rps_match_found`
|
||||
- `rps_match_start_countdown`
|
||||
|
||||
### 3. 取消匹配
|
||||
|
||||
地址:`DELETE /api/v1/games/rps/match`
|
||||
|
||||
参数:
|
||||
|
||||
- 无
|
||||
|
||||
返回值:
|
||||
|
||||
- `cancelled`: bool
|
||||
- `server_time_ms`
|
||||
|
||||
相关 IM:
|
||||
|
||||
- `rps_match_cancelled`
|
||||
|
||||
### 4. 查询当前对战
|
||||
|
||||
地址:`GET /api/v1/games/rps/matches/{match_id}`
|
||||
|
||||
参数:
|
||||
|
||||
- `match_id`
|
||||
|
||||
返回值:
|
||||
|
||||
- `match`
|
||||
- `current_round`
|
||||
- `server_time_ms`
|
||||
|
||||
相关 IM:
|
||||
|
||||
- 无。这个接口用于补偿 IM 丢失、乱序和客户端重连。
|
||||
|
||||
### 5. 提交手势
|
||||
|
||||
地址:`POST /api/v1/games/rps/matches/{match_id}/choices`
|
||||
|
||||
参数:
|
||||
|
||||
- `round_no`
|
||||
- `choice`: `rock/paper/scissor`
|
||||
|
||||
返回值:
|
||||
|
||||
- `accepted`: bool
|
||||
- `round_status`
|
||||
- `choice_deadline_ms`
|
||||
- `reveal_at_ms`
|
||||
- `server_time_ms`
|
||||
|
||||
相关 IM:
|
||||
|
||||
- `rps_choice_locked`
|
||||
- `rps_reveal_countdown`
|
||||
- `rps_round_revealed`
|
||||
- `rps_match_finished`
|
||||
|
||||
## IM 事件
|
||||
|
||||
所有 IM payload 必须包含:
|
||||
|
||||
- `event_id`
|
||||
- `event_type`
|
||||
- `app_code`
|
||||
- `match_id`
|
||||
- `field_code`
|
||||
- `target_user_id`
|
||||
- `match_version`
|
||||
- `server_time_ms`
|
||||
|
||||
### rps_match_found
|
||||
|
||||
用途:双方匹配成功。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `players`
|
||||
- `start_at_ms`
|
||||
|
||||
### rps_round_start
|
||||
|
||||
用途:进入新小局,可以开始选择。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `round_no`
|
||||
- `choice_deadline_ms`
|
||||
- `my_score`
|
||||
- `opponent_score`
|
||||
|
||||
### rps_choice_locked
|
||||
|
||||
用途:告知某个玩家已锁定手势。不给对方展示具体手势。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `round_no`
|
||||
- `actor_user_id`
|
||||
- `both_ready`
|
||||
|
||||
### rps_reveal_countdown
|
||||
|
||||
用途:双方都出完后展示 3、2、1。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `round_no`
|
||||
- `reveal_at_ms`
|
||||
|
||||
### rps_round_revealed
|
||||
|
||||
用途:公开双方手势和小局结果。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `round_no`
|
||||
- `player1_choice`
|
||||
- `player2_choice`
|
||||
- `result`
|
||||
- `winner_user_id`
|
||||
- `player1_score`
|
||||
- `player2_score`
|
||||
|
||||
### rps_match_finished
|
||||
|
||||
用途:整场结束。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `winner_user_id`
|
||||
- `result`: `player1_win/player2_win/draw`
|
||||
- `rounds`
|
||||
- `finished_at_ms`
|
||||
|
||||
## 状态推进
|
||||
|
||||
### JoinRPSMatch
|
||||
|
||||
事务内处理:
|
||||
|
||||
1. 检查用户是否已有 `waiting/matched/playing` 对局。
|
||||
2. 锁定同 `field_code` 最早等待用户:`FOR UPDATE SKIP LOCKED`。
|
||||
3. 找到对手则创建 `game_rps_matches` 和第 1 小局。
|
||||
4. 两个玩家各写一条 `rps_match_found` outbox。
|
||||
5. 找不到对手则插入 `game_rps_match_queue`。
|
||||
|
||||
### SubmitRPSChoice
|
||||
|
||||
事务内处理:
|
||||
|
||||
1. 校验用户属于 match。
|
||||
2. 校验 match 未结束、round 正在 `picking`。
|
||||
3. 校验未超过 `choice_deadline_ms`。
|
||||
4. 写入对应玩家 choice;重复提交返回当前状态,不覆盖已选手势。
|
||||
5. 如果双方都已选择,设置 `reveal_at_ms = now + 3000`,写 `rps_reveal_countdown` outbox。
|
||||
|
||||
### Timeout Worker
|
||||
|
||||
game-service 增加后台 worker:
|
||||
|
||||
- 扫描 `picking` 且 `choice_deadline_ms <= now` 的 round。
|
||||
- 按超时规则补结算。
|
||||
- 扫描 `reveal_countdown` 且 `reveal_at_ms <= now` 的 round。
|
||||
- 公开本局结果,推进下一局或结束整场。
|
||||
- 每次状态变化都写 outbox。
|
||||
|
||||
## 幂等和乱序
|
||||
|
||||
- 客户端按 `event_id` 去重。
|
||||
- 客户端按 `match_version` 丢弃旧消息。
|
||||
- 出拳天然幂等:同一用户同一局只能有一个 choice。
|
||||
- IM 延迟或丢失时,客户端调用 `GET /api/v1/games/rps/matches/{match_id}` 拉取权威状态。
|
||||
|
||||
## 测试方案
|
||||
|
||||
### 单元测试
|
||||
|
||||
- 胜负规则:石头剪刀布、平局、超时。
|
||||
- 先两胜:2:0、2:1、含平局、最多 5 小局平局。
|
||||
- 重复出拳不能覆盖已锁定手势。
|
||||
- 双方都出拳后进入 reveal countdown。
|
||||
|
||||
### 存储测试
|
||||
|
||||
- 并发 Join 同一场,只生成一场 match。
|
||||
- 同一用户不能同时进入多个 waiting/playing。
|
||||
- SubmitChoice 并发只保留第一条。
|
||||
- Timeout worker 能推进超时小局。
|
||||
|
||||
### 集成测试
|
||||
|
||||
- gateway HTTP -> game-service gRPC -> MySQL。
|
||||
- game-service outbox -> notice-service fake publisher。
|
||||
- IM payload 包含 `event_id`、`match_id`、`match_version`、倒计时字段。
|
||||
|
||||
## 开发顺序
|
||||
|
||||
1. 增加 proto 和 gateway HTTP 接口。
|
||||
2. 增加 `game_rps_*` 表和 runtime `Migrate`。
|
||||
3. 增加 domain 胜负规则和 service 匹配/出拳/结算逻辑。
|
||||
4. 增加 realtime outbox 和 timeout worker。
|
||||
5. notice-service 增加 game realtime outbox consumer,复用 `pkg/tencentim` C2C 自定义消息。
|
||||
6. 增加单元测试、存储测试、gateway handler 测试。
|
||||
7. 运行 `make proto`、目标包 `go test`、`docker compose config`。
|
||||
@ -27,6 +27,7 @@ const (
|
||||
adapterBaishunV1 = "baishun_v1"
|
||||
adapterZeeOneV1 = "zeeone_v1"
|
||||
adapterVivaGamesV1 = "vivagames_v1"
|
||||
adapterReyouV1 = "reyou_v1"
|
||||
defaultGameStatus = "disabled"
|
||||
defaultGameCategory = "casino"
|
||||
defaultLaunchMode = "full_screen"
|
||||
@ -134,6 +135,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
|
||||
return fetchZeeOneGameSyncPlan(platform, req)
|
||||
case adapterVivaGamesV1:
|
||||
return fetchVivaGamesGameSyncPlan(platform, req)
|
||||
case adapterReyouV1:
|
||||
return fetchReyouGameSyncPlan(platform, req)
|
||||
default:
|
||||
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。
|
||||
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
|
||||
@ -188,6 +191,14 @@ type vivaGamesSyncConfig struct {
|
||||
GameCovers map[string]string `json:"game_covers"`
|
||||
}
|
||||
|
||||
type reyouSyncConfig struct {
|
||||
// 热游文档没有游戏列表 API;后台以 game_urls 作为唯一清单来源,和服务端启动配置保持同一份 JSON。
|
||||
GameURLs map[string]string `json:"game_urls"`
|
||||
GameNames map[string]string `json:"game_names"`
|
||||
GameIcons map[string]string `json:"game_icons"`
|
||||
GameCovers map[string]string `json:"game_covers"`
|
||||
}
|
||||
|
||||
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
||||
if err != nil {
|
||||
@ -329,6 +340,67 @@ func vivaGamesCatalogItems(platformCode string, config vivaGamesSyncConfig, req
|
||||
return items, gameURLs
|
||||
}
|
||||
|
||||
func fetchReyouGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||
config, err := decodeReyouSyncConfig(platform.GetAdapterConfigJson())
|
||||
if err != nil {
|
||||
return providerGameSyncPlan{}, err
|
||||
}
|
||||
games, gameURLs := reyouCatalogItems(platform.GetPlatformCode(), config, req)
|
||||
if len(games) == 0 {
|
||||
return providerGameSyncPlan{}, fmt.Errorf("热游游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射")
|
||||
}
|
||||
return providerGameSyncPlan{
|
||||
Games: games,
|
||||
GameURLs: gameURLs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeReyouSyncConfig(raw string) (reyouSyncConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return reyouSyncConfig{}, nil
|
||||
}
|
||||
var config reyouSyncConfig
|
||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return reyouSyncConfig{}, fmt.Errorf("热游适配器配置 JSON 不合法")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func reyouCatalogItems(platformCode string, config reyouSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
|
||||
keys := make([]string, 0, len(config.GameURLs))
|
||||
for providerGameID, launchURL := range config.GameURLs {
|
||||
if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" {
|
||||
keys = append(keys, strings.TrimSpace(providerGameID))
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
items := make([]catalogRequest, 0, len(keys))
|
||||
gameURLs := make(map[string]string, len(keys))
|
||||
for index, providerGameID := range keys {
|
||||
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
|
||||
gameURLs[providerGameID] = launchURL
|
||||
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
|
||||
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
|
||||
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
|
||||
items = append(items, catalogRequest{
|
||||
GameID: stableGameID(platformCode, providerGameID),
|
||||
PlatformCode: strings.TrimSpace(platformCode),
|
||||
ProviderGameID: providerGameID,
|
||||
GameName: name,
|
||||
Category: defaulted(req.Category, defaultGameCategory),
|
||||
IconURL: iconURL,
|
||||
CoverURL: coverURL,
|
||||
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
|
||||
Orientation: defaultOrientation,
|
||||
MinCoin: req.MinCoin,
|
||||
Status: defaulted(req.Status, defaultGameStatus),
|
||||
SortOrder: int32((index + 1) * 10),
|
||||
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterReyouV1}, req.Tags...)),
|
||||
})
|
||||
}
|
||||
return items, gameURLs
|
||||
}
|
||||
|
||||
func titleGameName(value string) string {
|
||||
tokens := splitGameNameTokens(value)
|
||||
if len(tokens) == 0 {
|
||||
|
||||
@ -136,6 +136,37 @@ func TestFetchVivaGamesGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReyouGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
||||
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||
PlatformCode: "reyou",
|
||||
AdapterType: adapterReyouV1,
|
||||
AdapterConfigJson: `{
|
||||
"game_urls": {
|
||||
"101": "https://games.reyou.example/test/box/half/?game_id=101"
|
||||
},
|
||||
"game_names": {
|
||||
"101": "Hot Game"
|
||||
}
|
||||
}`,
|
||||
}, syncGamesRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
|
||||
}
|
||||
if len(plan.Games) != 1 {
|
||||
t.Fatalf("games len = %d, want 1", len(plan.Games))
|
||||
}
|
||||
game := plan.Games[0]
|
||||
if game.GameID != "reyou_101" || game.ProviderGameID != "101" || game.GameName != "Hot Game" {
|
||||
t.Fatalf("reyou game mismatch: %+v", game)
|
||||
}
|
||||
if plan.GameURLs["101"] != "https://games.reyou.example/test/box/half/?game_id=101" {
|
||||
t.Fatalf("game url mismatch: %+v", plan.GameURLs)
|
||||
}
|
||||
if game.Status != "disabled" || game.LaunchMode != "full_screen" || game.Orientation != "portrait" {
|
||||
t.Fatalf("default fields mismatch: %+v", game)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
|
||||
games := []catalogRequest{
|
||||
{ProviderGameID: "1001", GameName: "one"},
|
||||
|
||||
@ -19,6 +19,7 @@ const (
|
||||
AdapterZeeOneV1 = "zeeone_v1"
|
||||
AdapterBaishunV1 = "baishun_v1"
|
||||
AdapterVivaGamesV1 = "vivagames_v1"
|
||||
AdapterReyouV1 = "reyou_v1"
|
||||
|
||||
OrderStatusWalletApplying = "wallet_applying"
|
||||
OrderStatusSucceeded = "succeeded"
|
||||
|
||||
346
services/game-service/internal/service/game/reyou_adapter.go
Normal file
346
services/game-service/internal/service/game/reyou_adapter.go
Normal file
@ -0,0 +1,346 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||
)
|
||||
|
||||
const (
|
||||
// 热游文档只定义这几个业务码;HTTP 200 仅表示回调已被接收,游戏侧以 errorCode 判定业务结果。
|
||||
reyouCodeOK = 0
|
||||
reyouCodeTokenInvalid = 1001
|
||||
reyouCodeInsufficientBalance = 2001
|
||||
reyouCodeDuplicatedOrder = 3001
|
||||
|
||||
reyouDefaultTokenTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type reyouAdapterConfig struct {
|
||||
// uid_mode 必须同时约束启动 URL 和回调校验,避免同一 token 被不同用户标识复用。
|
||||
UIDMode string `json:"uid_mode"`
|
||||
DefaultLang string `json:"default_lang"`
|
||||
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
||||
// 热游文档没有提供游戏列表 API,后台用 game_urls 保存厂商给的逐游戏 H5 地址。
|
||||
GameURLs map[string]string `json:"game_urls"`
|
||||
LaunchURLTemplate string `json:"launch_url_template"`
|
||||
}
|
||||
|
||||
func reyouConfigFromPlatform(value any) reyouAdapterConfig {
|
||||
// 启动链路拿 LaunchableGame,回调链路拿 Platform;两者共用一套 JSON 字段,避免配置漂移。
|
||||
var raw string
|
||||
switch typed := value.(type) {
|
||||
case gamedomain.Platform:
|
||||
raw = typed.AdapterConfigJSON
|
||||
case gamedomain.LaunchableGame:
|
||||
raw = typed.AdapterConfigJSON
|
||||
}
|
||||
var config reyouAdapterConfig
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
||||
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
||||
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
||||
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
|
||||
config.GameURLs = normalizeStringMap(config.GameURLs)
|
||||
return config
|
||||
}
|
||||
|
||||
func (c reyouAdapterConfig) TokenTTL() time.Duration {
|
||||
// 热游要求 token 在游戏中以及延迟结算后至少 3 分钟仍有效;默认 24 小时覆盖长局和重试场景。
|
||||
if c.TokenTTLSeconds > 0 {
|
||||
return time.Duration(c.TokenTTLSeconds) * time.Second
|
||||
}
|
||||
return reyouDefaultTokenTTL
|
||||
}
|
||||
|
||||
func (c reyouAdapterConfig) LanguageValue() string {
|
||||
if c.DefaultLang != "" {
|
||||
return c.DefaultLang
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
func reyouLaunchBaseURL(game gamedomain.LaunchableGame, config reyouAdapterConfig) string {
|
||||
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
|
||||
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
if config.LaunchURLTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"{provider_game_id}", strings.TrimSpace(game.ProviderGameID),
|
||||
"{providerGameId}", strings.TrimSpace(game.ProviderGameID),
|
||||
"{game_id}", strings.TrimSpace(game.GameID),
|
||||
"{gameId}", strings.TrimSpace(game.GameID),
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate))
|
||||
}
|
||||
|
||||
func (s *Service) handleReyouOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
|
||||
// IP 白名单是热游正式环境侧的第一层来源校验;未配置时兼容测试环境任意来源。
|
||||
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "ip restricted", false, "")
|
||||
}
|
||||
switch operation {
|
||||
case "getuserinfo", "get_user_info", "get-user-info", "userinfo", "user_info":
|
||||
return s.handleReyouUserInfo(ctx, app, platform, req)
|
||||
case "updatebalance", "update_balance", "update-balance", "change_balance":
|
||||
return s.handleReyouUpdateBalance(ctx, app, platform, req, requestHash)
|
||||
default:
|
||||
raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(reyouCodeOK), false, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
type reyouUserInfoBody struct {
|
||||
GameID json.RawMessage `json:"gameId"`
|
||||
UID json.RawMessage `json:"uid"`
|
||||
Token json.RawMessage `json:"token"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
|
||||
func (body reyouUserInfoBody) gameIDText() string { return reyouJSONText(body.GameID) }
|
||||
func (body reyouUserInfoBody) uidText() string { return reyouJSONText(body.UID) }
|
||||
func (body reyouUserInfoBody) tokenText() string { return reyouJSONText(body.Token) }
|
||||
|
||||
type reyouUpdateBalanceBody struct {
|
||||
OrderID json.RawMessage `json:"orderId"`
|
||||
GameID json.RawMessage `json:"gameId"`
|
||||
RoundID json.RawMessage `json:"roundId"`
|
||||
UID json.RawMessage `json:"uid"`
|
||||
Coin json.RawMessage `json:"coin"`
|
||||
Type json.RawMessage `json:"type"`
|
||||
Token json.RawMessage `json:"token"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
|
||||
func (body reyouUpdateBalanceBody) orderIDText() string { return reyouJSONText(body.OrderID) }
|
||||
func (body reyouUpdateBalanceBody) gameIDText() string { return reyouJSONText(body.GameID) }
|
||||
func (body reyouUpdateBalanceBody) roundIDText() string { return reyouJSONText(body.RoundID) }
|
||||
func (body reyouUpdateBalanceBody) uidText() string { return reyouJSONText(body.UID) }
|
||||
func (body reyouUpdateBalanceBody) coinText() string { return reyouJSONText(body.Coin) }
|
||||
func (body reyouUpdateBalanceBody) typeText() string { return reyouJSONText(body.Type) }
|
||||
func (body reyouUpdateBalanceBody) tokenText() string { return reyouJSONText(body.Token) }
|
||||
|
||||
func (s *Service) handleReyouUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
|
||||
var body reyouUserInfoBody
|
||||
if err := decodeReyouJSON(req.GetRawBody(), &body); err != nil {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", false, "")
|
||||
}
|
||||
gameID := body.gameIDText()
|
||||
uid := body.uidText()
|
||||
token := body.tokenText()
|
||||
// 文档签名顺序固定为 gameId + uid + token + key;不能排序,也不能插入分隔符。
|
||||
if !reyouSignValid(body.Sign, platform.CallbackSecretCiphertext, gameID, uid, token) {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "signature invalid", false, "")
|
||||
}
|
||||
config := reyouConfigFromPlatform(platform)
|
||||
session, err := s.validReyouSession(ctx, app, token)
|
||||
// token 通过后继续绑定 uid/gameId,防止同一登录 token 被跨用户或跨游戏复用。
|
||||
if err != nil || !reyouSessionMatches(session, config, uid, gameID) {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "token invalid", true, "")
|
||||
}
|
||||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||||
if err != nil {
|
||||
return reyouAdapterError(reyouCodeFromError(err), reyouMessageFromError(err), true, "")
|
||||
}
|
||||
raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{
|
||||
"uid": uid,
|
||||
"nickname": snapshot.Nickname,
|
||||
"avatar": snapshot.Avatar,
|
||||
"coin": snapshot.Balance,
|
||||
"vipLevel": int64(0),
|
||||
})
|
||||
return raw, contentType, strconv.Itoa(reyouCodeOK), true, "", nil
|
||||
}
|
||||
|
||||
func (s *Service) handleReyouUpdateBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) {
|
||||
var body reyouUpdateBalanceBody
|
||||
if err := decodeReyouJSON(req.GetRawBody(), &body); err != nil {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", false, "")
|
||||
}
|
||||
providerOrderID := body.orderIDText()
|
||||
gameID := body.gameIDText()
|
||||
roundID := body.roundIDText()
|
||||
uid := body.uidText()
|
||||
coinText := body.coinText()
|
||||
typeText := body.typeText()
|
||||
token := body.tokenText()
|
||||
// 文档签名顺序固定为 orderId + gameId + roundId + uid + coin + type + token + key。
|
||||
if !reyouSignValid(body.Sign, platform.CallbackSecretCiphertext, providerOrderID, gameID, roundID, uid, coinText, typeText, token) {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "signature invalid", false, providerOrderID)
|
||||
}
|
||||
coin, coinOK := reyouInt64(coinText)
|
||||
opValue, typeOK := reyouInt32(typeText)
|
||||
opType := reyouOpType(opValue)
|
||||
if providerOrderID == "" || gameID == "" || uid == "" || token == "" || !coinOK || !typeOK || opType == "" || coin < 0 {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", true, providerOrderID)
|
||||
}
|
||||
config := reyouConfigFromPlatform(platform)
|
||||
session, err := s.validReyouSession(ctx, app, token)
|
||||
// 热游同时带 token、uid 和 gameId,三者必须落到同一个启动会话;JWT 直连时允许 gameId 后置补齐。
|
||||
if err != nil || !reyouSessionMatches(session, config, uid, gameID) {
|
||||
return reyouAdapterError(reyouCodeTokenInvalid, "token invalid", true, providerOrderID)
|
||||
}
|
||||
if session.ProviderGameID == "" {
|
||||
session.ProviderGameID = gameID
|
||||
}
|
||||
if session.GameID == "" {
|
||||
session.GameID = gameID
|
||||
}
|
||||
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, roundID, opType, coin, "", requestHash)
|
||||
if err != nil {
|
||||
code := reyouCodeFromError(err)
|
||||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||||
raw, contentType := reyouJSON(code, reyouMessageFromError(err), map[string]any{"coin": balance, "responseId": ""})
|
||||
return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil
|
||||
}
|
||||
if result.IdempotentReplay {
|
||||
// 热游文档要求重复 orderId 明确返回 3001;这里只回历史余额,不再次触发钱包改账。
|
||||
raw, contentType := reyouJSON(reyouCodeDuplicatedOrder, "duplicated order", map[string]any{"coin": result.BalanceAfter, "responseId": reyouResponseID(app, req.GetPlatformCode(), providerOrderID)})
|
||||
return raw, contentType, strconv.Itoa(reyouCodeDuplicatedOrder), true, providerOrderID, nil
|
||||
}
|
||||
raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{"coin": result.BalanceAfter, "responseId": reyouResponseID(app, req.GetPlatformCode(), providerOrderID)})
|
||||
return raw, contentType, strconv.Itoa(reyouCodeOK), true, providerOrderID, nil
|
||||
}
|
||||
|
||||
func (s *Service) validReyouSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
// App 当前把 access token 作为三方 token;JWT 要优先解析,避免旧 launch session 同 hash 但旧 gameId 误判。
|
||||
if strings.Count(token, ".") == 2 {
|
||||
if session, err := s.appSessionFromAccessToken(app, token); err == nil {
|
||||
return session, nil
|
||||
}
|
||||
}
|
||||
if session, err := s.validYomiSession(ctx, app, token); err == nil {
|
||||
return session, nil
|
||||
}
|
||||
if strings.Count(token, ".") == 2 {
|
||||
return s.appSessionFromAccessToken(app, token)
|
||||
}
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
|
||||
func reyouSessionMatches(session gamedomain.LaunchSession, config reyouAdapterConfig, uid string, gameID string) bool {
|
||||
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
|
||||
return false
|
||||
}
|
||||
uid = strings.TrimSpace(uid)
|
||||
return uid == externalUserID(session, config.UIDMode) ||
|
||||
uid == strings.TrimSpace(session.DisplayUserID) ||
|
||||
uid == strconv.FormatInt(session.UserID, 10)
|
||||
}
|
||||
|
||||
func reyouOpType(value int32) string {
|
||||
// 热游 type=1 表示扣减,type=2 表示增加;下游钱包只接受 debit/credit。
|
||||
switch value {
|
||||
case 1:
|
||||
return "debit"
|
||||
case 2:
|
||||
return "credit"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func decodeReyouJSON(raw []byte, out any) error {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
decoder.UseNumber()
|
||||
return decoder.Decode(out)
|
||||
}
|
||||
|
||||
func reyouJSONText(raw json.RawMessage) string {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if text == "" || text == "null" {
|
||||
return ""
|
||||
}
|
||||
var decoded string
|
||||
if err := json.Unmarshal(raw, &decoded); err == nil {
|
||||
return strings.TrimSpace(decoded)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func reyouInt64(value string) (int64, bool) {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func reyouInt32(value string) (int32, bool) {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 32)
|
||||
return int32(parsed), err == nil
|
||||
}
|
||||
|
||||
func reyouSignValid(actual string, key string, parts ...string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || strings.TrimSpace(actual) == "" {
|
||||
return false
|
||||
}
|
||||
builder := strings.Builder{}
|
||||
for _, part := range parts {
|
||||
builder.WriteString(strings.TrimSpace(part))
|
||||
}
|
||||
builder.WriteString(key)
|
||||
sum := md5.Sum([]byte(builder.String()))
|
||||
expected := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
return strings.EqualFold(strings.TrimSpace(actual), expected)
|
||||
}
|
||||
|
||||
func reyouResponseID(app string, platformCode string, providerOrderID string) string {
|
||||
return "reyou_" + stableHash(app+"|"+platformCode+"|"+providerOrderID)
|
||||
}
|
||||
|
||||
func reyouAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
|
||||
raw, contentType := reyouJSON(code, message, map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
|
||||
}
|
||||
|
||||
func reyouJSON(errorCode int, message string, data any) ([]byte, string) {
|
||||
payload := map[string]any{"errorCode": errorCode}
|
||||
if data != nil {
|
||||
payload["data"] = data
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
payload["message"] = strings.TrimSpace(message)
|
||||
}
|
||||
return jsonResponse(payload)
|
||||
}
|
||||
|
||||
func reyouCodeFromError(err error) int {
|
||||
switch {
|
||||
case err == nil:
|
||||
return reyouCodeOK
|
||||
case xerr.IsCode(err, xerr.InsufficientBalance):
|
||||
return reyouCodeInsufficientBalance
|
||||
case xerr.IsCode(err, xerr.IdempotencyConflict):
|
||||
return reyouCodeDuplicatedOrder
|
||||
default:
|
||||
return reyouCodeTokenInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func reyouMessageFromError(err error) string {
|
||||
switch reyouCodeFromError(err) {
|
||||
case reyouCodeInsufficientBalance:
|
||||
return "coin not enough"
|
||||
case reyouCodeDuplicatedOrder:
|
||||
return "duplicated order"
|
||||
default:
|
||||
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
|
||||
return xerr.MessageOf(err)
|
||||
}
|
||||
return "token invalid"
|
||||
}
|
||||
}
|
||||
@ -279,7 +279,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
||||
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
||||
token := strings.TrimSpace(command.AccessToken)
|
||||
if token == "" {
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
|
||||
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
|
||||
}
|
||||
token = "gt_" + randomHex(24)
|
||||
@ -296,6 +296,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
||||
sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
|
||||
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||
sessionTTL = maxDuration(sessionTTL, vivaGamesConfigFromPlatform(game).TokenTTL())
|
||||
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
|
||||
sessionTTL = maxDuration(sessionTTL, reyouConfigFromPlatform(game).TokenTTL())
|
||||
}
|
||||
expiresAt := now.Add(sessionTTL).UnixMilli()
|
||||
session := gamedomain.LaunchSession{
|
||||
@ -368,6 +370,8 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
|
||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleBaishunOperation(ctx, app, platform, req, operation, requestHash)
|
||||
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterVivaGamesV1):
|
||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleVivaGamesOperation(ctx, app, platform, req, operation, requestHash)
|
||||
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterReyouV1):
|
||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleReyouOperation(ctx, app, platform, req, operation, requestHash)
|
||||
default:
|
||||
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
|
||||
}
|
||||
@ -712,6 +716,12 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
base = configuredBase
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
|
||||
// 热游 H5 地址由厂商按游戏提供;后台配置 game_urls 后,上线新游戏无需改服务端配置文件。
|
||||
if configuredBase := reyouLaunchBaseURL(game, reyouConfigFromPlatform(game)); configuredBase != "" {
|
||||
base = configuredBase
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
|
||||
return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
|
||||
@ -725,6 +735,9 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||
return "", xerr.New(xerr.InvalidArgument, "vivagames launch url is required")
|
||||
}
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
|
||||
return "", xerr.New(xerr.InvalidArgument, "reyou launch url is required")
|
||||
}
|
||||
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
|
||||
base = "https://game.example.local/h5"
|
||||
}
|
||||
@ -854,6 +867,16 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
|
||||
// 热游文档只要求 uid/token/lang 三个启动参数;服务端回调再用 token 绑定用户、游戏和钱包订单。
|
||||
config := reyouConfigFromPlatform(game)
|
||||
query.Set("uid", externalUserID(session, config.UIDMode))
|
||||
query.Set("token", token)
|
||||
query.Set("lang", config.LanguageValue())
|
||||
appendBridgeScriptQuery(query, game)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
|
||||
query.Set("session_id", session.SessionID)
|
||||
query.Set("session_token", token)
|
||||
|
||||
@ -479,6 +479,72 @@ func TestLaunchGameBuildsVivaGamesURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchGameBuildsReyouURL(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{
|
||||
CatalogItem: gamedomain.CatalogItem{
|
||||
AppCode: "lalu",
|
||||
GameID: "reyou_101",
|
||||
PlatformCode: "reyou",
|
||||
ProviderGameID: "101",
|
||||
GameName: "Reyou Demo",
|
||||
Status: gamedomain.StatusActive,
|
||||
Orientation: "portrait",
|
||||
},
|
||||
PlatformStatus: gamedomain.StatusActive,
|
||||
APIBaseURL: "https://fallback.example.invalid/game.html",
|
||||
AdapterType: gamedomain.AdapterReyouV1,
|
||||
AdapterConfigJSON: `{
|
||||
"default_lang":"en",
|
||||
"token_ttl_seconds":86400,
|
||||
"uid_mode":"display_user_id",
|
||||
"bridge_script_url":"https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
|
||||
"bridge_script_version":"20260608.1",
|
||||
"bridge_script_sha256":"78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
|
||||
"game_urls":{"101":"https://games.reyou.example/test/box/half/?game_id=101"}
|
||||
}`,
|
||||
},
|
||||
}
|
||||
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
|
||||
AppCode: "lalu",
|
||||
UserID: 42,
|
||||
DisplayUserID: "420001",
|
||||
GameID: "reyou_101",
|
||||
RoomID: "room_1",
|
||||
AccessToken: "app_access_token_reyou",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchGame failed: %v", err)
|
||||
}
|
||||
if result.ExpiresAtMS != 1700086400000 {
|
||||
t.Fatalf("reyou launch must use adapter token ttl, got %+v", result)
|
||||
}
|
||||
parsed, err := url.Parse(result.LaunchURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse reyou launch url failed: %v", err)
|
||||
}
|
||||
if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://games.reyou.example/test/box/half/" {
|
||||
t.Fatalf("reyou launch url must use per-game configured url: %s", result.LaunchURL)
|
||||
}
|
||||
expected := map[string]string{
|
||||
"game_id": "101",
|
||||
"uid": "420001",
|
||||
"token": "app_access_token_reyou",
|
||||
"lang": "en",
|
||||
"bridge_script_url": "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
|
||||
"bridge_script_version": "20260608.1",
|
||||
"bridge_script_sha256": "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
|
||||
}
|
||||
for key, want := range expected {
|
||||
if got := parsed.Query().Get(key); got != want {
|
||||
t.Fatalf("reyou launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchGameRequiresBaishunLaunchURL(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{
|
||||
@ -1235,6 +1301,90 @@ func TestHandleVivaGamesTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReyouUserInfoAndUpdateBalance(t *testing.T) {
|
||||
secret := "reyou-test-key"
|
||||
token := "app_access_token_reyou"
|
||||
repo := &fakeRepository{
|
||||
platform: gamedomain.Platform{
|
||||
PlatformCode: "reyou",
|
||||
AdapterType: gamedomain.AdapterReyouV1,
|
||||
CallbackSecretCiphertext: secret,
|
||||
AdapterConfigJSON: `{"uid_mode":"display_user_id"}`,
|
||||
},
|
||||
session: gamedomain.LaunchSession{
|
||||
AppCode: "lalu",
|
||||
SessionID: "game_sess_reyou",
|
||||
UserID: 42,
|
||||
DisplayUserID: "420001",
|
||||
RoomID: "room_1",
|
||||
PlatformCode: "reyou",
|
||||
GameID: "reyou_101",
|
||||
ProviderGameID: "101",
|
||||
LaunchTokenHash: stableHash(token),
|
||||
Status: gamedomain.SessionActive,
|
||||
ExpiresAtMS: 1700086400000,
|
||||
},
|
||||
}
|
||||
wallet := &fakeWallet{balanceAfter: 1880}
|
||||
user := &fakeUser{}
|
||||
svc := New(Config{}, repo, wallet, user)
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
userInfoSign := reyouTestSign(secret, "101", "420001", token)
|
||||
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-reyou-userinfo", AppCode: "lalu"},
|
||||
PlatformCode: "reyou",
|
||||
Operation: "getUserInfo",
|
||||
RawBody: []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","sign":"` + userInfoSign + `"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback getUserInfo failed: %v", err)
|
||||
}
|
||||
if contentType != "application/json" || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"uid":"420001"`) || !strings.Contains(string(raw), `"coin":1880`) || !strings.Contains(string(raw), `"vipLevel":0`) {
|
||||
t.Fatalf("reyou userinfo response mismatch: %s", raw)
|
||||
}
|
||||
if user.lastGet.GetUserId() != 42 {
|
||||
t.Fatalf("user request mismatch: %+v", user.lastGet)
|
||||
}
|
||||
|
||||
changeSign := reyouTestSign(secret, "reyou_order_1", "101", "round_1", "420001", "120", "1", token)
|
||||
changeBody := `{"orderId":"reyou_order_1","gameId":"101","roundId":"round_1","uid":"420001","coin":120,"type":1,"token":"` + token + `","sign":"` + changeSign + `"}`
|
||||
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-reyou-change", AppCode: "lalu"},
|
||||
PlatformCode: "reyou",
|
||||
Operation: "updateBalance",
|
||||
RawBody: []byte(changeBody),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback updateBalance failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1880`) || !strings.Contains(string(raw), `"responseId":"reyou_`) {
|
||||
t.Fatalf("reyou change response mismatch: %s", raw)
|
||||
}
|
||||
if wallet.lastApply.GetCommandId() != "game:reyou:reyou_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "reyou_101" {
|
||||
t.Fatalf("reyou wallet command mismatch: %+v", wallet.lastApply)
|
||||
}
|
||||
if wallet.applyCount != 1 {
|
||||
t.Fatalf("wallet apply count = %d, want 1", wallet.applyCount)
|
||||
}
|
||||
|
||||
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-reyou-change-replay", AppCode: "lalu"},
|
||||
PlatformCode: "reyou",
|
||||
Operation: "updateBalance",
|
||||
RawBody: []byte(changeBody),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate updateBalance failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"errorCode":3001`) || !strings.Contains(string(raw), `"coin":1880`) {
|
||||
t.Fatalf("reyou duplicate response mismatch: %s", raw)
|
||||
}
|
||||
if wallet.applyCount != 1 {
|
||||
t.Fatalf("duplicate order must not apply wallet again, count=%d", wallet.applyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
||||
secret := "baishun-test-app-key"
|
||||
userID := int64(312900923573673984)
|
||||
@ -1653,6 +1803,16 @@ func leaderccTestSign(key string, parts ...string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func reyouTestSign(key string, parts ...string) string {
|
||||
builder := strings.Builder{}
|
||||
for _, part := range parts {
|
||||
builder.WriteString(strings.TrimSpace(part))
|
||||
}
|
||||
builder.WriteString(strings.TrimSpace(key))
|
||||
sum := md5.Sum([]byte(builder.String()))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func leaderccTestAccessToken(t *testing.T, app string, userID int64, displayUserID string, expiresAtSec int64) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
|
||||
@ -463,6 +463,8 @@ func normalizeAdapterType(adapterType string) string {
|
||||
return gamedomain.AdapterBaishunV1
|
||||
case gamedomain.AdapterVivaGamesV1:
|
||||
return gamedomain.AdapterVivaGamesV1
|
||||
case gamedomain.AdapterReyouV1:
|
||||
return gamedomain.AdapterReyouV1
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||
@ -23,20 +24,22 @@ func TestPlatformToProtoExposesCallbackSecretForAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformAcceptsVivaGamesAdapter(t *testing.T) {
|
||||
func TestNormalizePlatformAcceptsProviderAdapters(t *testing.T) {
|
||||
// 平台创建入口会先规范化 adapter_type;新增厂商必须在这里放行,否则后台配置页会被 gRPC 直接拒绝。
|
||||
got, err := normalizePlatform(gamedomain.Platform{
|
||||
PlatformCode: "vivagames",
|
||||
PlatformName: "VIVAGAMES",
|
||||
Status: gamedomain.StatusActive,
|
||||
AdapterType: gamedomain.AdapterVivaGamesV1,
|
||||
AdapterConfigJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePlatform() error = %v", err)
|
||||
}
|
||||
if got.AdapterType != gamedomain.AdapterVivaGamesV1 {
|
||||
t.Fatalf("adapter_type = %q, want %q", got.AdapterType, gamedomain.AdapterVivaGamesV1)
|
||||
for _, adapterType := range []string{gamedomain.AdapterVivaGamesV1, gamedomain.AdapterReyouV1} {
|
||||
got, err := normalizePlatform(gamedomain.Platform{
|
||||
PlatformCode: strings.TrimSuffix(adapterType, "_v1"),
|
||||
PlatformName: adapterType,
|
||||
Status: gamedomain.StatusActive,
|
||||
AdapterType: adapterType,
|
||||
AdapterConfigJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePlatform(adapter=%s) error = %v", adapterType, err)
|
||||
}
|
||||
if got.AdapterType != adapterType {
|
||||
t.Fatalf("adapter_type = %q, want %q", got.AdapterType, adapterType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ import (
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
@ -27,8 +29,16 @@ type weeklyStarCycleData struct {
|
||||
}
|
||||
|
||||
type weeklyStarGiftData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
GiftID string `json:"gift_id"`
|
||||
ResourceID int64 `json:"resource_id,omitempty"`
|
||||
Resource *achievementResourceData `json:"resource,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
AssetURL string `json:"asset_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
AnimationURL string `json:"animation_url,omitempty"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type weeklyStarRewardData struct {
|
||||
@ -103,8 +113,12 @@ func (h *Handler) getWeeklyStarCurrent(writer http.ResponseWriter, request *http
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gifts, ok := h.resolveWeeklyStarGifts(writer, request, resp.GetCycle())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data := weeklyStarCurrentData{
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), resourceGroups),
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), gifts, resourceGroups),
|
||||
TopEntries: weeklyStarEntriesFromProto(resp.GetTopEntries(), users),
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
}
|
||||
@ -142,8 +156,12 @@ func (h *Handler) listWeeklyStarLeaderboard(writer http.ResponseWriter, request
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gifts, ok := h.resolveWeeklyStarGifts(writer, request, resp.GetCycle())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, weeklyStarLeaderboardData{
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), resourceGroups),
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), gifts, resourceGroups),
|
||||
Entries: weeklyStarEntriesFromProto(resp.GetEntries(), users),
|
||||
NextPageToken: resp.GetNextPageToken(),
|
||||
Total: resp.GetTotal(),
|
||||
@ -181,10 +199,14 @@ func (h *Handler) listWeeklyStarHistory(writer http.ResponseWriter, request *htt
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
gifts, ok := h.resolveWeeklyStarGifts(writer, request, cycleItems...)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cycles := make([]weeklyStarHistoryCycleData, 0, len(resp.GetCycles()))
|
||||
for _, item := range resp.GetCycles() {
|
||||
cycles = append(cycles, weeklyStarHistoryCycleData{
|
||||
Cycle: weeklyStarCycleFromProto(item.GetCycle(), resourceGroups),
|
||||
Cycle: weeklyStarCycleFromProto(item.GetCycle(), gifts, resourceGroups),
|
||||
TopEntries: weeklyStarEntriesFromProto(item.GetTopEntries(), users),
|
||||
})
|
||||
}
|
||||
@ -234,7 +256,51 @@ func (h *Handler) resolveWeeklyStarResourceGroups(writer http.ResponseWriter, re
|
||||
return h.resolveAchievementResourceGroups(writer, request, weeklyStarResourceGroupIDs(cycles...))
|
||||
}
|
||||
|
||||
func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, resourceGroups map[int64]achievementResourceGroupData) *weeklyStarCycleData {
|
||||
func (h *Handler) resolveWeeklyStarGifts(writer http.ResponseWriter, request *http.Request, cycles ...*activityv1.WeeklyStarCycle) (map[string]weeklyStarGiftData, bool) {
|
||||
ids := weeklyStarGiftIDs(cycles...)
|
||||
gifts := make(map[string]weeklyStarGiftData, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return gifts, true
|
||||
}
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 周星只保存活动礼物 ID,封面图和名称来自 wallet-service 的礼物配置。
|
||||
// ListGiftConfigs 当前没有按 gift_id 批量查询,所以这里分页拉取 active 礼物,并在命中全部活动礼物后立即停止。
|
||||
const pageSize int32 = 500
|
||||
remaining := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
remaining[id] = struct{}{}
|
||||
}
|
||||
for page := int32(1); ; page++ {
|
||||
resp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
ActiveOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return nil, false
|
||||
}
|
||||
for _, gift := range resp.GetGifts() {
|
||||
id := strings.TrimSpace(gift.GetGiftId())
|
||||
if _, needed := remaining[id]; !needed {
|
||||
continue
|
||||
}
|
||||
gifts[id] = weeklyStarGiftFromProto(gift)
|
||||
delete(remaining, id)
|
||||
}
|
||||
if len(remaining) == 0 || len(resp.GetGifts()) == 0 || int64(page)*int64(pageSize) >= resp.GetTotal() {
|
||||
return gifts, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, gifts map[string]weeklyStarGiftData, resourceGroups map[int64]achievementResourceGroupData) *weeklyStarCycleData {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
@ -253,7 +319,12 @@ func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, resourceGroups m
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
for _, gift := range item.GetGifts() {
|
||||
cycle.Gifts = append(cycle.Gifts, weeklyStarGiftData{GiftID: gift.GetGiftId(), SortOrder: gift.GetSortOrder()})
|
||||
giftData := weeklyStarGiftData{GiftID: gift.GetGiftId(), SortOrder: gift.GetSortOrder()}
|
||||
if enriched, ok := gifts[strings.TrimSpace(gift.GetGiftId())]; ok {
|
||||
giftData = enriched
|
||||
giftData.SortOrder = gift.GetSortOrder()
|
||||
}
|
||||
cycle.Gifts = append(cycle.Gifts, giftData)
|
||||
}
|
||||
for _, reward := range item.GetRewards() {
|
||||
rewardData := weeklyStarRewardData{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()}
|
||||
@ -265,6 +336,42 @@ func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, resourceGroups m
|
||||
return cycle
|
||||
}
|
||||
|
||||
func weeklyStarGiftFromProto(gift *walletv1.GiftConfig) weeklyStarGiftData {
|
||||
resource := achievementResourceFromProto(gift.GetResource())
|
||||
imageURL := weeklyStarFirstNonEmpty(resource.PreviewURL, resource.AssetURL, resource.AnimationURL)
|
||||
return weeklyStarGiftData{
|
||||
GiftID: gift.GetGiftId(),
|
||||
ResourceID: gift.GetResourceId(),
|
||||
Resource: achievementResourcePointerFromProto(gift.GetResource()),
|
||||
Name: gift.GetName(),
|
||||
ImageURL: imageURL,
|
||||
CoverURL: imageURL,
|
||||
AssetURL: resource.AssetURL,
|
||||
PreviewURL: resource.PreviewURL,
|
||||
AnimationURL: resource.AnimationURL,
|
||||
SortOrder: gift.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
|
||||
func weeklyStarGiftIDs(cycles ...*activityv1.WeeklyStarCycle) []string {
|
||||
seen := make(map[string]struct{})
|
||||
ids := make([]string, 0)
|
||||
for _, cycle := range cycles {
|
||||
for _, gift := range cycle.GetGifts() {
|
||||
id := strings.TrimSpace(gift.GetGiftId())
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func weeklyStarEntriesFromProto(items []*activityv1.WeeklyStarLeaderboardEntry, users map[int64]weeklyStarUserData) []weeklyStarEntryData {
|
||||
entries := make([]weeklyStarEntryData, 0, len(items))
|
||||
for _, item := range items {
|
||||
@ -308,6 +415,15 @@ func weeklyStarResourceGroupIDs(cycles ...*activityv1.WeeklyStarCycle) []int64 {
|
||||
return ids
|
||||
}
|
||||
|
||||
func weeklyStarFirstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func queryInt(request *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get(key)))
|
||||
if err != nil || value <= 0 {
|
||||
|
||||
@ -4979,6 +4979,16 @@ func TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
||||
giftResource := &walletv1.Resource{
|
||||
ResourceId: 7308,
|
||||
ResourceCode: "weekly_star_gift_308",
|
||||
ResourceType: "gift",
|
||||
Name: "Star Ball Gift",
|
||||
Status: "active",
|
||||
AssetUrl: "https://cdn.example/gift-308.svga",
|
||||
PreviewUrl: "https://cdn.example/gift-308.png",
|
||||
SortOrder: 1,
|
||||
}
|
||||
rewardResource := &walletv1.Resource{
|
||||
ResourceId: 8101,
|
||||
ResourceCode: "weekly_star_frame",
|
||||
@ -5022,6 +5032,19 @@ func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
||||
},
|
||||
}
|
||||
walletClient := &fakeWalletClient{
|
||||
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
|
||||
Gifts: []*walletv1.GiftConfig{{
|
||||
AppCode: "lalu",
|
||||
GiftId: "308",
|
||||
ResourceId: giftResource.GetResourceId(),
|
||||
Resource: giftResource,
|
||||
Status: "active",
|
||||
Name: "Star Ball",
|
||||
SortOrder: 7,
|
||||
CoinPrice: 100000,
|
||||
}},
|
||||
Total: 1,
|
||||
},
|
||||
resourceGroupsByID: map[int64]*walletv1.ResourceGroup{23: {
|
||||
GroupId: 23,
|
||||
GroupCode: "weekly_star_top1",
|
||||
@ -5067,6 +5090,16 @@ func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Cycle struct {
|
||||
Gifts []struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
Resource struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
} `json:"resource"`
|
||||
} `json:"gifts"`
|
||||
Rewards []struct {
|
||||
RankNo int32 `json:"rank_no"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
@ -5100,6 +5133,12 @@ func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
||||
if weeklyStarClient.lastCurrent == nil || weeklyStarClient.lastCurrent.GetRegionId() != 686 || weeklyStarClient.lastCurrent.GetUserId() != 42 {
|
||||
t.Fatalf("weekly star current request mismatch: %+v", weeklyStarClient.lastCurrent)
|
||||
}
|
||||
if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetAppCode() != "lalu" || !walletClient.lastListGiftConfigs.GetActiveOnly() || walletClient.lastListGiftConfigs.GetPageSize() != 500 {
|
||||
t.Fatalf("weekly star gift preload request mismatch: %+v", walletClient.lastListGiftConfigs)
|
||||
}
|
||||
if len(envelope.Data.Cycle.Gifts) != 1 || envelope.Data.Cycle.Gifts[0].GiftID != "308" || envelope.Data.Cycle.Gifts[0].Name != "Star Ball" || envelope.Data.Cycle.Gifts[0].ImageURL != "https://cdn.example/gift-308.png" || envelope.Data.Cycle.Gifts[0].PreviewURL != "https://cdn.example/gift-308.png" || envelope.Data.Cycle.Gifts[0].Resource.ResourceID != 7308 {
|
||||
t.Fatalf("weekly star gift must embed cover material details: %+v", envelope.Data.Cycle.Gifts)
|
||||
}
|
||||
firstReward := envelope.Data.Cycle.Rewards[0]
|
||||
if envelope.Code != httpkit.CodeOK || firstReward.ResourceGroupID != 23 || firstReward.ResourceGroup.GroupID != 23 || firstReward.ResourceGroup.Name != "Top 1 Resource Group" {
|
||||
t.Fatalf("weekly star reward group envelope mismatch: %+v", envelope)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user