三方游戏
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"
|
adapterBaishunV1 = "baishun_v1"
|
||||||
adapterZeeOneV1 = "zeeone_v1"
|
adapterZeeOneV1 = "zeeone_v1"
|
||||||
adapterVivaGamesV1 = "vivagames_v1"
|
adapterVivaGamesV1 = "vivagames_v1"
|
||||||
|
adapterReyouV1 = "reyou_v1"
|
||||||
defaultGameStatus = "disabled"
|
defaultGameStatus = "disabled"
|
||||||
defaultGameCategory = "casino"
|
defaultGameCategory = "casino"
|
||||||
defaultLaunchMode = "full_screen"
|
defaultLaunchMode = "full_screen"
|
||||||
@ -134,6 +135,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
|
|||||||
return fetchZeeOneGameSyncPlan(platform, req)
|
return fetchZeeOneGameSyncPlan(platform, req)
|
||||||
case adapterVivaGamesV1:
|
case adapterVivaGamesV1:
|
||||||
return fetchVivaGamesGameSyncPlan(platform, req)
|
return fetchVivaGamesGameSyncPlan(platform, req)
|
||||||
|
case adapterReyouV1:
|
||||||
|
return fetchReyouGameSyncPlan(platform, req)
|
||||||
default:
|
default:
|
||||||
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。
|
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。
|
||||||
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
|
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
|
||||||
@ -188,6 +191,14 @@ type vivaGamesSyncConfig struct {
|
|||||||
GameCovers map[string]string `json:"game_covers"`
|
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) {
|
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||||
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -329,6 +340,67 @@ func vivaGamesCatalogItems(platformCode string, config vivaGamesSyncConfig, req
|
|||||||
return items, gameURLs
|
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 {
|
func titleGameName(value string) string {
|
||||||
tokens := splitGameNameTokens(value)
|
tokens := splitGameNameTokens(value)
|
||||||
if len(tokens) == 0 {
|
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) {
|
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
|
||||||
games := []catalogRequest{
|
games := []catalogRequest{
|
||||||
{ProviderGameID: "1001", GameName: "one"},
|
{ProviderGameID: "1001", GameName: "one"},
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const (
|
|||||||
AdapterZeeOneV1 = "zeeone_v1"
|
AdapterZeeOneV1 = "zeeone_v1"
|
||||||
AdapterBaishunV1 = "baishun_v1"
|
AdapterBaishunV1 = "baishun_v1"
|
||||||
AdapterVivaGamesV1 = "vivagames_v1"
|
AdapterVivaGamesV1 = "vivagames_v1"
|
||||||
|
AdapterReyouV1 = "reyou_v1"
|
||||||
|
|
||||||
OrderStatusWalletApplying = "wallet_applying"
|
OrderStatusWalletApplying = "wallet_applying"
|
||||||
OrderStatusSucceeded = "succeeded"
|
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)
|
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
||||||
token := strings.TrimSpace(command.AccessToken)
|
token := strings.TrimSpace(command.AccessToken)
|
||||||
if token == "" {
|
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")
|
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
|
||||||
}
|
}
|
||||||
token = "gt_" + randomHex(24)
|
token = "gt_" + randomHex(24)
|
||||||
@ -296,6 +296,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
|||||||
sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
|
sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
|
||||||
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
sessionTTL = maxDuration(sessionTTL, vivaGamesConfigFromPlatform(game).TokenTTL())
|
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()
|
expiresAt := now.Add(sessionTTL).UnixMilli()
|
||||||
session := gamedomain.LaunchSession{
|
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)
|
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleBaishunOperation(ctx, app, platform, req, operation, requestHash)
|
||||||
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterVivaGamesV1):
|
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterVivaGamesV1):
|
||||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleVivaGamesOperation(ctx, app, platform, req, operation, requestHash)
|
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:
|
default:
|
||||||
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
|
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
|
base = configuredBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
|
||||||
|
// 热游 H5 地址由厂商按游戏提供;后台配置 game_urls 后,上线新游戏无需改服务端配置文件。
|
||||||
|
if configuredBase := reyouLaunchBaseURL(game, reyouConfigFromPlatform(game)); configuredBase != "" {
|
||||||
|
base = configuredBase
|
||||||
|
}
|
||||||
|
}
|
||||||
if base == "" {
|
if base == "" {
|
||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
|
||||||
return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
|
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) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
return "", xerr.New(xerr.InvalidArgument, "vivagames launch url is required")
|
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 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
|
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
|
||||||
base = "https://game.example.local/h5"
|
base = "https://game.example.local/h5"
|
||||||
}
|
}
|
||||||
@ -854,6 +867,16 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
|||||||
parsed.RawQuery = query.Encode()
|
parsed.RawQuery = query.Encode()
|
||||||
return parsed.String(), nil
|
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 和订单链路。
|
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
|
||||||
query.Set("session_id", session.SessionID)
|
query.Set("session_id", session.SessionID)
|
||||||
query.Set("session_token", token)
|
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) {
|
func TestLaunchGameRequiresBaishunLaunchURL(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
launchable: gamedomain.LaunchableGame{
|
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) {
|
func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
||||||
secret := "baishun-test-app-key"
|
secret := "baishun-test-app-key"
|
||||||
userID := int64(312900923573673984)
|
userID := int64(312900923573673984)
|
||||||
@ -1653,6 +1803,16 @@ func leaderccTestSign(key string, parts ...string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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 {
|
func leaderccTestAccessToken(t *testing.T, app string, userID int64, displayUserID string, expiresAtSec int64) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
|||||||
@ -463,6 +463,8 @@ func normalizeAdapterType(adapterType string) string {
|
|||||||
return gamedomain.AdapterBaishunV1
|
return gamedomain.AdapterBaishunV1
|
||||||
case gamedomain.AdapterVivaGamesV1:
|
case gamedomain.AdapterVivaGamesV1:
|
||||||
return gamedomain.AdapterVivaGamesV1
|
return gamedomain.AdapterVivaGamesV1
|
||||||
|
case gamedomain.AdapterReyouV1:
|
||||||
|
return gamedomain.AdapterReyouV1
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package grpc
|
package grpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
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 直接拒绝。
|
// 平台创建入口会先规范化 adapter_type;新增厂商必须在这里放行,否则后台配置页会被 gRPC 直接拒绝。
|
||||||
got, err := normalizePlatform(gamedomain.Platform{
|
for _, adapterType := range []string{gamedomain.AdapterVivaGamesV1, gamedomain.AdapterReyouV1} {
|
||||||
PlatformCode: "vivagames",
|
got, err := normalizePlatform(gamedomain.Platform{
|
||||||
PlatformName: "VIVAGAMES",
|
PlatformCode: strings.TrimSuffix(adapterType, "_v1"),
|
||||||
Status: gamedomain.StatusActive,
|
PlatformName: adapterType,
|
||||||
AdapterType: gamedomain.AdapterVivaGamesV1,
|
Status: gamedomain.StatusActive,
|
||||||
AdapterConfigJSON: "{}",
|
AdapterType: adapterType,
|
||||||
})
|
AdapterConfigJSON: "{}",
|
||||||
if err != nil {
|
})
|
||||||
t.Fatalf("normalizePlatform() error = %v", err)
|
if err != nil {
|
||||||
}
|
t.Fatalf("normalizePlatform(adapter=%s) error = %v", adapterType, err)
|
||||||
if got.AdapterType != gamedomain.AdapterVivaGamesV1 {
|
}
|
||||||
t.Fatalf("adapter_type = %q, want %q", got.AdapterType, gamedomain.AdapterVivaGamesV1)
|
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"
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
userv1 "hyapp.local/api/proto/user/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/auth"
|
||||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
)
|
)
|
||||||
@ -27,8 +29,16 @@ type weeklyStarCycleData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type weeklyStarGiftData struct {
|
type weeklyStarGiftData struct {
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
SortOrder int32 `json:"sort_order"`
|
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 {
|
type weeklyStarRewardData struct {
|
||||||
@ -103,8 +113,12 @@ func (h *Handler) getWeeklyStarCurrent(writer http.ResponseWriter, request *http
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
gifts, ok := h.resolveWeeklyStarGifts(writer, request, resp.GetCycle())
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
data := weeklyStarCurrentData{
|
data := weeklyStarCurrentData{
|
||||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), resourceGroups),
|
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), gifts, resourceGroups),
|
||||||
TopEntries: weeklyStarEntriesFromProto(resp.GetTopEntries(), users),
|
TopEntries: weeklyStarEntriesFromProto(resp.GetTopEntries(), users),
|
||||||
ServerTimeMS: resp.GetServerTimeMs(),
|
ServerTimeMS: resp.GetServerTimeMs(),
|
||||||
}
|
}
|
||||||
@ -142,8 +156,12 @@ func (h *Handler) listWeeklyStarLeaderboard(writer http.ResponseWriter, request
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
gifts, ok := h.resolveWeeklyStarGifts(writer, request, resp.GetCycle())
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
httpkit.WriteOK(writer, request, weeklyStarLeaderboardData{
|
httpkit.WriteOK(writer, request, weeklyStarLeaderboardData{
|
||||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), resourceGroups),
|
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), gifts, resourceGroups),
|
||||||
Entries: weeklyStarEntriesFromProto(resp.GetEntries(), users),
|
Entries: weeklyStarEntriesFromProto(resp.GetEntries(), users),
|
||||||
NextPageToken: resp.GetNextPageToken(),
|
NextPageToken: resp.GetNextPageToken(),
|
||||||
Total: resp.GetTotal(),
|
Total: resp.GetTotal(),
|
||||||
@ -181,10 +199,14 @@ func (h *Handler) listWeeklyStarHistory(writer http.ResponseWriter, request *htt
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
gifts, ok := h.resolveWeeklyStarGifts(writer, request, cycleItems...)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
cycles := make([]weeklyStarHistoryCycleData, 0, len(resp.GetCycles()))
|
cycles := make([]weeklyStarHistoryCycleData, 0, len(resp.GetCycles()))
|
||||||
for _, item := range resp.GetCycles() {
|
for _, item := range resp.GetCycles() {
|
||||||
cycles = append(cycles, weeklyStarHistoryCycleData{
|
cycles = append(cycles, weeklyStarHistoryCycleData{
|
||||||
Cycle: weeklyStarCycleFromProto(item.GetCycle(), resourceGroups),
|
Cycle: weeklyStarCycleFromProto(item.GetCycle(), gifts, resourceGroups),
|
||||||
TopEntries: weeklyStarEntriesFromProto(item.GetTopEntries(), users),
|
TopEntries: weeklyStarEntriesFromProto(item.GetTopEntries(), users),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -234,7 +256,51 @@ func (h *Handler) resolveWeeklyStarResourceGroups(writer http.ResponseWriter, re
|
|||||||
return h.resolveAchievementResourceGroups(writer, request, weeklyStarResourceGroupIDs(cycles...))
|
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 {
|
if item == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -253,7 +319,12 @@ func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, resourceGroups m
|
|||||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
for _, gift := range item.GetGifts() {
|
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() {
|
for _, reward := range item.GetRewards() {
|
||||||
rewardData := weeklyStarRewardData{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()}
|
rewardData := weeklyStarRewardData{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()}
|
||||||
@ -265,6 +336,42 @@ func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, resourceGroups m
|
|||||||
return cycle
|
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 {
|
func weeklyStarEntriesFromProto(items []*activityv1.WeeklyStarLeaderboardEntry, users map[int64]weeklyStarUserData) []weeklyStarEntryData {
|
||||||
entries := make([]weeklyStarEntryData, 0, len(items))
|
entries := make([]weeklyStarEntryData, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@ -308,6 +415,15 @@ func weeklyStarResourceGroupIDs(cycles ...*activityv1.WeeklyStarCycle) []int64 {
|
|||||||
return ids
|
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 {
|
func queryInt(request *http.Request, key string, fallback int) int {
|
||||||
value, err := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get(key)))
|
value, err := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get(key)))
|
||||||
if err != nil || value <= 0 {
|
if err != nil || value <= 0 {
|
||||||
|
|||||||
@ -4979,6 +4979,16 @@ func TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWeeklyStarCurrentEmbedsRewardResourceGroups(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{
|
rewardResource := &walletv1.Resource{
|
||||||
ResourceId: 8101,
|
ResourceId: 8101,
|
||||||
ResourceCode: "weekly_star_frame",
|
ResourceCode: "weekly_star_frame",
|
||||||
@ -5022,6 +5032,19 @@ func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
walletClient := &fakeWalletClient{
|
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: {
|
resourceGroupsByID: map[int64]*walletv1.ResourceGroup{23: {
|
||||||
GroupId: 23,
|
GroupId: 23,
|
||||||
GroupCode: "weekly_star_top1",
|
GroupCode: "weekly_star_top1",
|
||||||
@ -5067,6 +5090,16 @@ func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
|||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Cycle 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 {
|
Rewards []struct {
|
||||||
RankNo int32 `json:"rank_no"`
|
RankNo int32 `json:"rank_no"`
|
||||||
ResourceGroupID int64 `json:"resource_group_id"`
|
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 {
|
if weeklyStarClient.lastCurrent == nil || weeklyStarClient.lastCurrent.GetRegionId() != 686 || weeklyStarClient.lastCurrent.GetUserId() != 42 {
|
||||||
t.Fatalf("weekly star current request mismatch: %+v", weeklyStarClient.lastCurrent)
|
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]
|
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" {
|
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)
|
t.Fatalf("weekly star reward group envelope mismatch: %+v", envelope)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user