# 游戏接入架构

本文定义语音房 H5 弹窗游戏的服务边界、平台接入、金币改账、App 游戏列表、后台管理和补单对账方案。游戏可以来自百顺、灵仙、即构、小游等多个平台，但 App 侧看到的是一个合并后的游戏列表。

## 目标

- 在语音房内打开 H5 弹窗游戏，支持多个第三方游戏平台并行接入。
- App 获取统一游戏列表，不关心游戏来自哪个平台，但返回字段必须包含 `platform_code` 便于排查和埋点。
- 第三方平台回调可以获取用户信息、查询余额、变更用户金币和补单。
- 后台增加一级菜单 `游戏管理`，二级菜单 `游戏列表`，列表按平台筛选和维护。
- 所有金币变化仍由 `wallet-service` 落账，游戏平台、gateway、admin 和 room-service 都不能直接改金币余额。

## 非目标

- 不把游戏状态放进 Room Cell。房间只负责展示入口、房间上下文和必要的 IM 通知。
- 不让第三方平台直接访问 wallet-service 或 user-service。
- 不用 Redis 保存金币、订单或补单事实。Redis 只可用于短期 launch token、限流和幂等热缓存。
- 不把不同平台的协议字段直接扩散到 App、room-service 或 wallet-service。

## 服务边界

新增 `game-service`，端口建议使用 `13008`，MySQL 数据库建议使用 `hyapp_game`。

| 服务 | 职责 |
|---|---|
| `gateway-service` | App HTTP 入口；游戏列表、启动游戏、第三方平台公网回调入口；鉴权、协议转换、原始请求体透传。 |
| `game-service` | 游戏平台配置、游戏目录、展示规则、H5 启动会话、平台回调验签、平台协议适配、游戏订单、补单和对账任务 owner。 |
| `wallet-service` | 用户 `COIN` 的唯一 owner；处理游戏扣款、派奖、退款、补单入账和幂等流水。 |
| `user-service` | 用户基础资料 owner；向 game-service 提供平台回调需要的用户资料投影。 |
| `room-service` | 语音房状态 owner；只提供 `room_id` 上下文和可选的在房校验，不处理游戏订单或金币。 |
| `server/admin` | 后台 RBAC、菜单、审计和后台 HTTP API；通过 game-service gRPC 管理平台和游戏配置。 |
| `cron-service` | 只触发 game-service 的补单/对账批处理，不直接扫游戏表或修改钱包。 |

```mermaid
flowchart LR
  App["App voice room"] -->|"GET /api/v1/games"| Gateway["gateway-service"]
  App -->|"POST /api/v1/games/{game_id}/launch"| Gateway
  Provider["game platform callback"] -->|"provider HTTP callback"| Gateway
  Admin["admin frontend"] -->|"admin HTTP"| AdminServer["server/admin"]

  Gateway -->|"gRPC"| Game["game-service"]
  AdminServer -->|"gRPC"| Game
  Game -->|"gRPC ApplyGameCoinChange"| Wallet["wallet-service"]
  Game -->|"gRPC user profile"| User["user-service"]
  Game -. optional .->|"validate room context"| Room["room-service"]

  Game --> GameDB[("hyapp_game")]
  Wallet --> WalletDB[("hyapp_wallet")]
```

## 平台适配模型

每个平台实现一个 adapter，把第三方协议转换成内部统一命令。

```go
type PlatformAdapter interface {
    VerifyCallback(rawBody []byte, headers map[string]string, query map[string]string, secret PlatformSecret) error
    ParseOperation(path string, rawBody []byte) (ProviderOperation, error)
    BuildResponse(result ProviderResult) ([]byte, string, error)
    BuildLaunchURL(session LaunchSession, user GameUser, game GameConfig) (string, error)
}
```

内部统一操作只保留业务语义：

| 内部操作 | 说明 |
|---|---|
| `get_user_info` | 平台获取用户 ID、昵称、头像、余额等启动或运行信息。 |
| `get_balance` | 平台查询用户当前金币余额。 |
| `change_coin` | 平台发起扣款、派奖、退款、冲正等金币变化。 |
| `repair_order` | 平台或后台补单，重放遗漏订单或修复平台异常订单。 |
| `query_order` | 平台查询内部订单处理结果。 |

平台差异只能停留在 adapter 层。进入 game-service 领域层以后，所有金额单位、订单 ID、用户 ID、游戏 ID、状态机和错误码都必须统一。

## 数据模型

### game_platforms

游戏平台配置表。

```sql
game_platforms(
  app_code VARCHAR(32) NOT NULL,
  platform_code VARCHAR(64) NOT NULL,
  platform_name VARCHAR(128) NOT NULL,
  status VARCHAR(32) NOT NULL,
  api_base_url VARCHAR(512) NOT NULL DEFAULT '',
  callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
  callback_ip_whitelist JSON NULL,
  adapter_config JSON NULL,
  sort_order INT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, platform_code)
);
```

`status` 建议取值：

| 状态 | 语义 |
|---|---|
| `active` | 可展示、可启动、可接收回调。 |
| `maintenance` | App 可展示维护态，不允许启动新会话；已发生订单仍可接收回调。 |
| `disabled` | 不展示、不允许启动；回调只允许查询历史订单或按配置拒绝。 |

### game_catalog

第三方游戏目录表，一条记录对应一个平台下的一个游戏。

```sql
game_catalog(
  app_code VARCHAR(32) NOT NULL,
  game_id VARCHAR(96) NOT NULL,
  platform_code VARCHAR(64) NOT NULL,
  provider_game_id VARCHAR(128) NOT NULL,
  game_name VARCHAR(128) NOT NULL,
  category VARCHAR(64) NOT NULL DEFAULT '',
  icon_url VARCHAR(512) NOT NULL DEFAULT '',
  cover_url VARCHAR(512) NOT NULL DEFAULT '',
  launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
  orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
  min_coin BIGINT NOT NULL DEFAULT 0,
  status VARCHAR(32) NOT NULL,
  sort_order INT NOT NULL DEFAULT 0,
  tags JSON NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, game_id),
  UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
  KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
);
```

`game_id` 是内部稳定 ID，App、订单和补单都使用它；`provider_game_id` 只在 adapter 和平台请求中使用。

### game_display_rules

控制游戏在 App 内的展示范围。

```sql
game_display_rules(
  app_code VARCHAR(32) NOT NULL,
  rule_id VARCHAR(96) NOT NULL,
  game_id VARCHAR(96) NOT NULL,
  scene VARCHAR(64) NOT NULL,
  region_id BIGINT NOT NULL DEFAULT 0,
  language VARCHAR(32) NOT NULL DEFAULT '',
  platform VARCHAR(16) NOT NULL DEFAULT '',
  visible TINYINT(1) NOT NULL DEFAULT 1,
  enabled TINYINT(1) NOT NULL DEFAULT 1,
  sort_order INT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, rule_id),
  KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
);
```

`scene` 当前先支持 `voice_room`，后续可以扩展首页、活动页或个人中心入口。`region_id=0` 表示全区域默认规则，具体区域规则优先级高于默认规则。

### game_launch_sessions

启动会话表，绑定用户、房间、游戏和平台短期令牌。

```sql
game_launch_sessions(
  app_code VARCHAR(32) NOT NULL,
  session_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  display_user_id VARCHAR(32) NOT NULL,
  room_id VARCHAR(96) NOT NULL DEFAULT '',
  scene VARCHAR(64) NOT NULL,
  platform_code VARCHAR(64) NOT NULL,
  game_id VARCHAR(96) NOT NULL,
  provider_game_id VARCHAR(128) NOT NULL,
  launch_token_hash VARCHAR(128) NOT NULL,
  status VARCHAR(32) NOT NULL,
  expires_at_ms BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, session_id),
  KEY idx_game_launch_user(app_code, user_id, created_at_ms),
  KEY idx_game_launch_token(app_code, launch_token_hash)
);
```

H5 URL 里只放短期 `session_token` 或 provider 需要的签名参数，不直接暴露钱包能力。会话过期后不允许继续用它拉用户信息，但历史订单仍按 provider order id 幂等处理。

### game_orders

游戏金币变化订单表。

```sql
game_orders(
  app_code VARCHAR(32) NOT NULL,
  order_id VARCHAR(96) NOT NULL,
  platform_code VARCHAR(64) NOT NULL,
  provider_order_id VARCHAR(160) NOT NULL,
  provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
  game_id VARCHAR(96) NOT NULL,
  provider_game_id VARCHAR(128) NOT NULL,
  user_id BIGINT NOT NULL,
  room_id VARCHAR(96) NOT NULL DEFAULT '',
  op_type VARCHAR(32) NOT NULL,
  coin_amount BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
  wallet_balance_after BIGINT NOT NULL DEFAULT 0,
  request_hash VARCHAR(128) NOT NULL,
  raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
  failure_code VARCHAR(64) NOT NULL DEFAULT '',
  failure_message VARCHAR(256) NOT NULL DEFAULT '',
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, order_id),
  UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
  KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
  KEY idx_game_order_round(app_code, platform_code, provider_round_id)
);
```

`op_type` 建议取值：

| 类型 | 钱包语义 |
|---|---|
| `debit` | 用户下注或购买局内道具，扣 `COIN`。 |
| `credit` | 用户中奖或平台派奖，加 `COIN`。 |
| `refund` | 扣款后失败退款，加 `COIN`，必须关联原订单。 |
| `reverse` | 平台冲正，按平台规则转成扣款或退款，不允许直接传负数。 |

订单状态机：

```mermaid
stateDiagram-v2
  [*] --> received
  received --> wallet_applying
  wallet_applying --> succeeded
  wallet_applying --> failed
  wallet_applying --> conflict
  succeeded --> refunded
  succeeded --> reversed
  failed --> repair_pending
  repair_pending --> wallet_applying
```

### game_callback_logs

回调日志表用于审计、排错和补偿，不作为金币事实。

```sql
game_callback_logs(
  app_code VARCHAR(32) NOT NULL,
  callback_id VARCHAR(96) NOT NULL,
  platform_code VARCHAR(64) NOT NULL,
  operation VARCHAR(64) NOT NULL,
  request_id VARCHAR(96) NOT NULL DEFAULT '',
  provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
  signature_valid TINYINT(1) NOT NULL DEFAULT 0,
  request_hash VARCHAR(128) NOT NULL,
  response_code VARCHAR(64) NOT NULL DEFAULT '',
  response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
  status VARCHAR(32) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, callback_id),
  KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
);
```

原始请求体如果较大，存对象存储或压缩表；主表只保留 hash 和引用，避免日志表膨胀影响查询。

### game_repair_orders

补单任务表，来源可以是平台回调、后台手动、对账任务。

```sql
game_repair_orders(
  app_code VARCHAR(32) NOT NULL,
  repair_id VARCHAR(96) NOT NULL,
  platform_code VARCHAR(64) NOT NULL,
  provider_order_id VARCHAR(160) NOT NULL,
  reason VARCHAR(128) NOT NULL,
  status VARCHAR(32) NOT NULL,
  operator_admin_id BIGINT NOT NULL DEFAULT 0,
  attempts INT NOT NULL DEFAULT 0,
  next_run_at_ms BIGINT NOT NULL DEFAULT 0,
  last_error_code VARCHAR(64) NOT NULL DEFAULT '',
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY(app_code, repair_id),
  UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
);
```

补单只能重放到 `game_orders` 和 `wallet-service` 幂等命令，不能直接 `UPDATE wallet_accounts`。

## 钱包改账

钱包底层统一表达为“用户金币增加或减少 + 业务类型”，最终都落到 `wallet_transactions` 和 `wallet_entries`。RPC 层不要暴露裸的通用 `ChangeCoin(user_id, amount, type)` 给所有业务直接调用，而是按业务场景封装入口；游戏场景固定由 game-service 调 wallet-service 的 `ApplyGameCoinChange`，wallet-service 内部再转换成统一账本分录。

game-service 调 wallet-service 新增内部 RPC：

```proto
message ApplyGameCoinChangeRequest {
  RequestMeta meta = 1;
  string command_id = 2;
  int64 user_id = 3;
  string platform_code = 4;
  string game_id = 5;
  string provider_order_id = 6;
  string provider_round_id = 7;
  string op_type = 8;
  int64 coin_amount = 9;
  string room_id = 10;
  string request_hash = 11;
}

message ApplyGameCoinChangeResponse {
  string wallet_transaction_id = 1;
  int64 balance_after = 2;
  bool idempotent_replay = 3;
}
```

`command_id` 固定为：

```text
game:{platform_code}:{provider_order_id}
```

钱包侧要求：

- `coin_amount` 必须是正整数，方向由 `op_type` 决定。
- `op_type` 只表达业务方向，最终统计口径落在 `wallet_transactions.biz_type` 和 `wallet_entries.available_delta`。
- `app_code + command_id` 唯一幂等，同一命令重复请求返回同一结果。
- 同一 `command_id` 携带不同 `request_hash` 必须返回冲突，不允许二次改账。
- `debit` 余额不足时不写成功交易、不写分录、不写 outbox。
- `credit/refund/reverse` 必须写 `wallet_transactions`、`wallet_entries` 和 `wallet_outbox`。
- 游戏账务 `biz_type` 至少包括 `game_debit`、`game_credit`、`game_refund`、`game_reverse`。

统一账本落账示例：

| 游戏动作 | `op_type` | `wallet_transactions.biz_type` | `wallet_entries.asset_type` | `wallet_entries.available_delta` |
|---|---|---|---|---:|
| 下注扣金币 | `debit` | `game_debit` | `COIN` | `-coin_amount` |
| 中奖派奖 | `credit` | `game_credit` | `COIN` | `+coin_amount` |
| 失败退款 | `refund` | `game_refund` | `COIN` | `+coin_amount` |
| 平台冲正 | `reverse` | `game_reverse` | `COIN` | 按冲正规则转换 |

用户金币收支统计只查 `wallet_entries`：

```sql
SELECT biz_type, SUM(available_delta) AS coin_delta
FROM wallet_entries
WHERE app_code = ?
  AND user_id = ?
  AND asset_type = 'COIN'
  AND created_at_ms >= ?
  AND created_at_ms < ?
GROUP BY biz_type;
```

## App 接口

### 获取合并游戏列表

```http
GET /api/v1/games?scene=voice_room&room_id={room_id}
```

返回：

```json
{
  "games": [
    {
      "game_id": "bs_rocket_001",
      "platform_code": "baishun",
      "name_key": "game.bs_rocket_001.name",
      "name": "Rocket",
      "icon_url": "https://cdn.example.com/game/rocket.png",
      "cover_url": "https://cdn.example.com/game/rocket-cover.png",
      "category": "casual",
      "launch_mode": "h5_popup",
      "orientation": "portrait",
      "min_coin": 100,
      "enabled": true,
      "maintenance": false,
      "sort_order": 10
    }
  ],
  "server_time_ms": 1778490000000
}
```

列表聚合规则：

- 只返回 `game_platforms.status in active/maintenance` 且 `game_catalog.status in active/maintenance` 的游戏。
- `maintenance=true` 时可以展示但不能启动。
- 根据用户区域、语言、平台和 `scene` 过滤 `game_display_rules`。
- 返回是多个平台合并排序，不按平台分组；后台才按平台筛选。

### 启动游戏

```http
POST /api/v1/games/{game_id}/launch
```

请求：

```json
{
  "scene": "voice_room",
  "room_id": "room_123"
}
```

返回：

```json
{
  "session_id": "game_sess_1778490000_abcd",
  "launch_url": "https://provider.example.com/h5?...",
  "expires_at_ms": 1778490900000,
  "orientation": "portrait"
}
```

启动时校验：

- 用户必须已登录且 onboarding completed。
- 游戏和平台不能是 `disabled`。
- `maintenance` 状态拒绝启动。
- 如果 `scene=voice_room` 且产品要求必须在房间内，game-service 通过 room-service 做只读校验；校验失败不创建启动会话。

## 第三方平台回调

公网入口统一放 gateway-service：

```http
POST /api/v1/game-callbacks/{platform_code}/{operation}
```

gateway 行为：

- 保留原始 body、query、header，不提前解析成统一 JSON。
- 生成 `request_id` 并透传到内部调用。
- 按 `platform_code` 转发给 game-service。
- 回调响应不套 App 业务 envelope，因为第三方通常要求固定响应格式。

game-service 行为：

1. 读取平台配置和 adapter。
2. 验签、IP 白名单、时间戳/nonce 防重放。
3. 写 `game_callback_logs`。
4. adapter 解析为内部 `ProviderOperation`。
5. 需要用户资料时读 user-service；需要余额或改账时读/写 wallet-service。
6. 写 `game_orders` 或 `game_repair_orders`。
7. adapter 构造平台要求的响应。

## 补单和对账

补单来源：

- 平台主动补单回调。
- 后台管理员按 `platform_code + provider_order_id` 手动补单。
- cron-service 触发 game-service 扫描 `game_repair_orders`。
- 后续平台支持订单查询时，由 game-service 拉取平台订单对账。

补单约束：

- 补单必须复用原始 `provider_order_id` 生成同一个钱包 `command_id`。
- 钱包幂等命中时视为补单成功，但必须记录 `idempotent_replay=true`。
- 原订单请求 hash 不一致时进入 `conflict`，需要人工处理。
- 后台手动补单必须写 admin 审计，包含操作人、原因和平台订单号。

## 后台管理

新增一级菜单：

```text
游戏管理
└── 游戏列表
```

游戏列表默认按平台 tab 或筛选展示：

| 字段 | 说明 |
|---|---|
| 平台 | 百顺、灵仙、即构、小游等 `platform_code/platform_name`。 |
| 游戏 ID | 内部 `game_id` 和平台 `provider_game_id`。 |
| 名称/图标 | App 展示资源。 |
| 类目 | casual、slot、board 等运营类目。 |
| 状态 | active、maintenance、disabled。 |
| 展示范围 | scene、区域、语言、端。 |
| 最低金币 | App 展示和启动前提示用。 |
| 排序 | 合并列表排序。 |
| 回调状态 | 最近回调成功率、最近错误码。 |

后台接口由 `server/admin` 提供：

```http
GET    /api/v1/admin/game/platforms
POST   /api/v1/admin/game/platforms
PATCH  /api/v1/admin/game/platforms/{platform_code}
GET    /api/v1/admin/game/games?platform_code=baishun
POST   /api/v1/admin/game/games
PATCH  /api/v1/admin/game/games/{game_id}
PATCH  /api/v1/admin/game/games/{game_id}/status
GET    /api/v1/admin/game/orders
GET    /api/v1/admin/game/callback-logs
POST   /api/v1/admin/game/repair-orders
```

`server/admin` 只做权限、审计和后台协议；平台配置和游戏目录事实仍写入 game-service。

当前第一期已落地平台配置和游戏目录维护接口，订单查询、回调日志查询和手动补单入口随补单状态机一起实现。

## 并发与可靠性

- game-service 可以多实例无状态部署；订单事实在 MySQL，短期 session 可同时写 MySQL 和 Redis。
- 同一用户多游戏并发改金币时，由 wallet-service 对 `wallet_accounts(app_code,user_id,COIN)` 行锁和命令幂等保证余额正确。
- 平台重复回调必须返回幂等结果，不允许重复扣款或重复派奖。
- 平台回调乱序时，game-service 只按 provider order id 和 round id 记录事实；是否允许先派奖后扣款由平台 adapter 配置决定。
- game_callback_logs 不能作为成功事实；成功事实是 `game_orders.status=succeeded` 加 wallet 成功交易。
- game-service outbox 只发布游戏订单完成、补单完成等事件；钱包余额变化仍以 wallet outbox 为准。

## 安全要求

- 每个平台必须独立 secret，支持轮换；后台只能看到 masked secret。
- 回调验签使用原始 body，不使用重新序列化后的 JSON。
- 支持 IP 白名单，但 IP 白名单失败不能替代签名校验。
- 请求时间戳和 nonce 防重放；nonce 可短期放 Redis，最终仍以 provider order id 幂等兜底。
- H5 启动 token 短有效期，绑定 `app_code/user_id/game_id/platform_code/room_id`。
- 所有 provider 回调、补单、后台状态变更必须写审计日志。
- 金币金额使用整数，不允许 float，不允许负数金额表达方向。

## 监控指标

| 指标 | 说明 |
|---|---|
| `game_callback_total{platform,operation,status}` | 平台回调量和状态。 |
| `game_callback_verify_failed_total{platform}` | 验签失败和白名单失败。 |
| `game_order_total{platform,op_type,status}` | 游戏订单状态。 |
| `game_wallet_apply_duration_ms{platform,op_type}` | 调钱包耗时。 |
| `game_wallet_conflict_total{platform}` | 幂等冲突数量。 |
| `game_repair_pending_total{platform}` | 待补单数量。 |
| `game_launch_total{platform,game_id,status}` | 游戏启动量和失败原因。 |

## 落地顺序

1. 新增 `game-service` 空服务、`hyapp_game` schema、配置和 docker-compose 端口 `13008`。
2. 定义 `api/proto/game/v1/game.proto`，包含 App 列表、启动、后台管理和回调处理 RPC。
3. wallet-service 增加 `ApplyGameCoinChange` RPC 和 `game_*` 账务类型测试。
4. game-service 实现平台配置、游戏目录、展示规则和 App 合并列表。
5. game-service 实现 H5 launch session，gateway 暴露 `/api/v1/games` 和 `/api/v1/games/{game_id}/launch`。
6. 实现第一个平台 adapter，先打通 `get_user_info/get_balance/change_coin`。
7. 增加 `game_orders`、`game_callback_logs`、补单幂等和冲突处理。
8. server/admin 增加 `游戏管理 / 游戏列表`，接入 game-service 管理接口。
9. 增加补单后台入口和 cron-service 触发的 game-service 补偿批处理。
10. 接入第二个平台前，固化 adapter 测试基线，避免平台差异污染核心领域层。

## 验收清单

- App 能在语音房拿到多个平台合并后的游戏列表。
- 禁用平台、维护平台、禁用游戏、区域不匹配游戏不会被错误启动。
- 启动游戏返回短期 H5 URL，过期 session 不能再换取用户信息。
- 平台扣金币成功后 wallet 余额减少，重复回调不重复扣。
- 平台派奖成功后 wallet 余额增加，重复回调不重复加。
- 同一 provider order id 不同 payload 返回冲突并进入人工处理。
- 余额不足时 game_orders 失败，wallet 不写成功交易。
- 后台可以按平台维护游戏、上下架、排序和查看回调日志。
- 后台补单只能通过 game-service 和 wallet-service 幂等命令完成，不能直接改余额。
- cron-service 只触发补单批处理，不直接拥有游戏或钱包状态。
