From e818542d93e33ac78e49290322101e41e2409000 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 9 Jun 2026 11:04:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E6=96=B9=E6=B8=B8=E6=88=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/猜拳对战游戏方案.md | 466 ++++++++++++++++++ .../modules/gamemanagement/sync_games.go | 72 +++ .../modules/gamemanagement/sync_games_test.go | 31 ++ .../game-service/internal/domain/game/game.go | 1 + .../internal/service/game/reyou_adapter.go | 346 +++++++++++++ .../internal/service/game/service.go | 25 +- .../internal/service/game/service_test.go | 160 ++++++ .../internal/transport/grpc/server.go | 2 + .../internal/transport/grpc/server_test.go | 29 +- .../http/activityapi/weekly_star_handler.go | 130 ++++- .../internal/transport/http/response_test.go | 39 ++ 11 files changed, 1280 insertions(+), 21 deletions(-) create mode 100644 docs/猜拳对战游戏方案.md create mode 100644 services/game-service/internal/service/game/reyou_adapter.go diff --git a/docs/猜拳对战游戏方案.md b/docs/猜拳对战游戏方案.md new file mode 100644 index 00000000..94306bdc --- /dev/null +++ b/docs/猜拳对战游戏方案.md @@ -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`。 diff --git a/server/admin/internal/modules/gamemanagement/sync_games.go b/server/admin/internal/modules/gamemanagement/sync_games.go index f4195818..14f80450 100644 --- a/server/admin/internal/modules/gamemanagement/sync_games.go +++ b/server/admin/internal/modules/gamemanagement/sync_games.go @@ -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 { diff --git a/server/admin/internal/modules/gamemanagement/sync_games_test.go b/server/admin/internal/modules/gamemanagement/sync_games_test.go index acfd8be6..b61bbd01 100644 --- a/server/admin/internal/modules/gamemanagement/sync_games_test.go +++ b/server/admin/internal/modules/gamemanagement/sync_games_test.go @@ -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"}, diff --git a/services/game-service/internal/domain/game/game.go b/services/game-service/internal/domain/game/game.go index 6b6319de..f008ca90 100644 --- a/services/game-service/internal/domain/game/game.go +++ b/services/game-service/internal/domain/game/game.go @@ -19,6 +19,7 @@ const ( AdapterZeeOneV1 = "zeeone_v1" AdapterBaishunV1 = "baishun_v1" AdapterVivaGamesV1 = "vivagames_v1" + AdapterReyouV1 = "reyou_v1" OrderStatusWalletApplying = "wallet_applying" OrderStatusSucceeded = "succeeded" diff --git a/services/game-service/internal/service/game/reyou_adapter.go b/services/game-service/internal/service/game/reyou_adapter.go new file mode 100644 index 00000000..8207a92e --- /dev/null +++ b/services/game-service/internal/service/game/reyou_adapter.go @@ -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" + } +} diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index 0fc20413..10852b04 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -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) diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index f6c288ff..f3e45d87 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -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{ diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 8f91ca7d..dc384b4a 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -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 "" } diff --git a/services/game-service/internal/transport/grpc/server_test.go b/services/game-service/internal/transport/grpc/server_test.go index 0d4ca170..1ec25ef9 100644 --- a/services/game-service/internal/transport/grpc/server_test.go +++ b/services/game-service/internal/transport/grpc/server_test.go @@ -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) + } } } diff --git a/services/gateway-service/internal/transport/http/activityapi/weekly_star_handler.go b/services/gateway-service/internal/transport/http/activityapi/weekly_star_handler.go index 681066ac..8807c986 100644 --- a/services/gateway-service/internal/transport/http/activityapi/weekly_star_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/weekly_star_handler.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 19b52a2b..9b62eaef 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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)