三方游戏

This commit is contained in:
zhx 2026-06-09 11:04:31 +08:00
parent d4b2bdb789
commit e818542d93
11 changed files with 1280 additions and 21 deletions

View 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 millisecondsIM 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`

View File

@ -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 {

View File

@ -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"},

View File

@ -19,6 +19,7 @@ const (
AdapterZeeOneV1 = "zeeone_v1"
AdapterBaishunV1 = "baishun_v1"
AdapterVivaGamesV1 = "vivagames_v1"
AdapterReyouV1 = "reyou_v1"
OrderStatusWalletApplying = "wallet_applying"
OrderStatusSucceeded = "succeeded"

View 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 作为三方 tokenJWT 要优先解析,避免旧 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"
}
}

View File

@ -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)

View File

@ -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{

View File

@ -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 ""
}

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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)