# game-service 游戏结构设计

本文定义 `game-service` 后续承载游戏的结构边界。当前仓库已经有第三方 H5 游戏接入能力：游戏列表、启动会话、厂商回调、钱包改账、统计 outbox 和等级事件 outbox 都在 `game-service` 内。本文补的是自研小游戏的结构，避免把骰子、猜拳这类玩法塞进第三方平台 adapter。

## 结论

- `game-service` 是游戏事实 owner。游戏列表、启动会话、匹配、局内状态、结算、补偿和游戏事件都归它管理。
- `gateway-service` 只做 App HTTP 入口、鉴权、参数转换和 gRPC 转发，不保存游戏状态。
- `wallet-service` 仍然是金币 owner。`game-service` 只能通过钱包 gRPC 扣款、派奖、退款，不能直接改余额。
- `room-service` 不拥有游戏状态。房间只提供入口、房间上下文和必要的展示事件。
- 第三方 H5 游戏继续走现有 `platform/catalog/session/callback/order` 结构。
- 自研小游戏单独按 `game_code` 建领域模块、服务模块和表，不提前抽一个复杂通用引擎。
- 只有当两个以上自研游戏出现相同匹配、计时、结算、IM 投递逻辑时，再把公共能力抽到 `internal/domain/minigame` 或 `internal/service/minigame`。

## 当前事实

现有入口：

- App 游戏列表：`GET /api/v1/games`
- 最近游戏：`GET /api/v1/games/recent`
- 桥接脚本：`GET /api/v1/games/bridge-script`
- 启动游戏：`POST /api/v1/games/{game_id}/launch`
- 厂商回调：`POST /api/v1/game-callbacks/{platform_code}/{operation}`

现有内部 RPC：

- `GameAppService.ListGames`
- `GameAppService.ListRecentGames`
- `GameAppService.GetBridgeScript`
- `GameAppService.LaunchGame`
- `GameCallbackService.HandleCallback`
- `GameAdminService` 管理平台和目录
- `GameCronService.ProcessLevelEventOutboxBatch`

现有核心表：

- `game_platforms`：第三方平台配置
- `game_catalog`：App 看到的游戏目录
- `game_display_rules`：按场景、区域、语言和客户端平台控制展示
- `game_launch_sessions`：H5 启动会话
- `game_orders`：第三方或游戏结算引起的钱包订单
- `game_callback_logs`：第三方回调审计
- `game_level_event_outbox`：游戏消耗投递 activity-service
- `game_outbox`：游戏订单事实投递 RocketMQ，给 statistics-service 等下游消费

## 分层结构

```text
gateway-service
  internal/transport/http/gameapi
    只接 App HTTP，拿 user_id、request_id、app_code 后转 game-service gRPC

api/proto/game/v1
  game.proto
    所有跨服务契约，新增 App 接口或后台接口先改这里

services/game-service
  internal/domain/game
    已有通用游戏目录、平台、会话、订单、回调、outbox 领域对象

  internal/service/game
    已有第三方 H5 平台接入和游戏列表、启动、回调主流程

  internal/domain/<game_code>
    自研游戏纯规则，不能依赖数据库、gRPC、HTTP、钱包 client

  internal/service/<game_code>
    自研游戏用例层，负责匹配、状态推进、调用钱包、写 outbox

  internal/storage/mysql/<game_code>_repository.go
    自研游戏持久化，行锁、幂等、状态转移都在这里收口

  internal/transport/grpc
    把 game.proto 的 RPC 转成 service 调用，不写业务规则
```

目录原则：

- `internal/service/game` 保留给已有平台接入，不继续堆自研玩法。
- 每个自研玩法用独立包，例如 `internal/domain/dice`、`internal/service/dice`。
- 自研玩法可以复用 `game_catalog` 和 `game_display_rules` 做列表展示。
- 自研玩法不走第三方 `PlatformAdapter`，也不走 `HandleCallback`。
- 自研玩法的 HTTP 入口仍在 gateway，跨服务契约仍在 `api/proto/game/v1/game.proto`。

## 游戏类型

| 类型             | 例子                   | 列表和启动                                        | 状态 owner                              | 结算方式                                      |
| ---------------- | ---------------------- | ------------------------------------------------- | --------------------------------------- | --------------------------------------------- |
| 第三方 H5        | 百顺、VivaGames、Reyou | 现有 `/games` 和 `/launch`                        | 第三方平台，game-service 保存会话和订单 | 厂商回调到 game-service，再调 wallet-service  |
| 自研 H5 多人结算 | 骰子首版               | 复用 `/games` 和 `/launch`，局内操作走新增 HTTP   | game-service                            | game-service 直接调 wallet-service            |
| 自研实时对战     | 猜拳、后续双人骰子     | 复用 `/games` 和 `/launch`，匹配和出招走新增 HTTP | game-service                            | game-service 调 wallet-service，IM 只下发事件 |

## 自研游戏统一链路

```mermaid
sequenceDiagram
  participant App
  participant Gateway as gateway-service
  participant Game as game-service
  participant Wallet as wallet-service
  participant Notice as notice-service/Tencent IM
  participant DB as hyapp_game

  App->>Gateway: POST /api/v1/games/{game_id}/launch
  Gateway->>Game: LaunchGame
  Game->>DB: create game_launch_sessions
  Game-->>Gateway: launch_url/session_id
  Gateway-->>App: H5 url

  App->>Gateway: POST /api/v1/games/dice/matches
  Gateway->>Game: Create self-game match
  Game->>DB: create match + first participant
  App->>Gateway: POST /api/v1/games/dice/matches/{match_id}/join
  Gateway->>Game: JoinDiceMatch
  Game->>DB: lock match, insert participant
  App->>Gateway: POST /api/v1/games/dice/matches/{match_id}/roll
  Gateway->>Game: RollDiceMatch
  Game->>DB: claim match for settling
  Game->>Wallet: ApplyGameCoinChange debit/credit/refund
  Wallet-->>Game: wallet_transaction_id/balance_after
  Game->>DB: save dice points, orders, result and outbox
  Game-->>Gateway: match/result state
  Gateway-->>App: business response
  Game-->>Notice: optional realtime outbox
  Notice-->>App: optional IM custom event
```

关键约束：

- 用户身份只信任 gateway 从 JWT 得到的 `user_id`。
- `request_id` 只做链路追踪，不做业务幂等键。
- 业务幂等键使用 `command_id`、`match_id`、`round_id`、`provider_order_id` 或唯一索引。
- 所有时间字段使用 UTC epoch milliseconds，字段名统一 `*_ms`。
- 金币金额必须是正数，扣款、派奖、退款用 `op_type` 表达方向。
- 同一局的投注、派奖、退款必须能通过 `match_id/round_id/order_id` 追踪。

## 自研游戏表结构原则

不要用一个大表兼容所有玩法。每个玩法先建自己的事实表，公共 outbox 可以复用。

建议公共表：

### game_realtime_outbox

用于自研游戏的 IM 实时事件。不要复用 `game_outbox`，因为 `game_outbox` 是订单和统计事实，字段强依赖 `order_id/op_type/coin_amount`。

关键字段：

- `app_code`
- `event_id`
- `event_type`
- `game_code`
- `match_id`
- `target_user_id`
- `payload_json`
- `status`
- `worker_id`
- `lock_until_ms`
- `retry_count`
- `next_retry_at_ms`
- `last_error`
- `created_at_ms`
- `updated_at_ms`

推荐索引：

- `PRIMARY KEY(app_code, event_id)`
- `idx_game_realtime_claim(app_code, status, next_retry_at_ms, created_at_ms, event_id)`
- `idx_game_realtime_target(app_code, target_user_id, created_at_ms)`

## Redis 和 MySQL 边界

局信息可以同时出现在 MySQL 和 Redis，但不能是“双重事实”。

- MySQL 是唯一事实。参与人、扣款、派奖、结果、状态流转和补偿都必须能只靠 MySQL 恢复。
- Redis 只保存热状态。可以存等待队列、房间当前局快照、倒计时、座位临时锁和防重复点击标记。
- Redis 数据必须可丢。Redis 重启、Key 过期或缓存击穿时，服务必须从 MySQL 查回当前局。
- 金币相关状态只以 MySQL 和 wallet-service 返回为准，不以 Redis 为准。
- 关键状态推进用 MySQL 事务、唯一索引和行锁收口；Redis 锁只能减少并发压力，不能作为最终防线。

推荐 Key：

- `game:dice:room:{app_code}:{room_id}:active_match`：房间当前局快照，TTL 按局时长加缓冲。
- `game:dice:match:{app_code}:{match_id}`：局详情快照，TTL 按局时长加缓冲。
- `game:dice:join_lock:{app_code}:{match_id}`：短 TTL 加入锁，用来减少并发抢座。
- `game:dice:action:{app_code}:{match_id}:{user_id}:{action}`：短 TTL 动作防抖，最终幂等仍看 MySQL。

写入顺序：

1. 先在 MySQL 事务里锁定 `game_dice_matches`。
2. 创建/加入只写 `game_dice_participants` 和局状态。
3. 开奖阶段用钱包幂等键写 `game_orders`，再保存点数、派奖和局结果。
4. MySQL 事实提交后刷新 Redis 快照。
5. Redis 刷新失败只记录日志和重试，不回滚已提交的 MySQL 事实。

## 骰子游戏首版结构

首版按多人可扩展结构设计。当前实现要求至少 2 人，默认最多 2 人，硬上限 6 人；3 人局只增加参与者行，不改表。H5 只负责展示和发起操作，随机、扣款、派奖和结果都由 `game-service` 完成。

当前骰子规则是单局胜场制。每个参与者每轮只扔 1 颗骰子，服务端把这个点数写入 `dice_points[0]`，点数高的一方获胜。同点不是终局，不退本金、不派奖，H5 展示点数 2 秒后继续调用 `roll`，同一个 `match_id` 会一直重投到分出胜负。

### 模块

```text
services/game-service/internal/domain/dice
  rule.go       赔率、可选押注、结果计算
  match.go      match 状态、参与者、局次、结果

services/game-service/internal/service/dice
  service.go    创建局、加入局、扣款、开奖、派奖、查询

services/game-service/internal/storage/mysql/dice_repository.go
  创建局、参与人行锁读取、状态推进、订单关联、查询
```

### 状态机

```mermaid
stateDiagram-v2
  [*] --> created
  created --> joining
  joining --> settling
  created --> settling
  settling --> payout_applying
  payout_applying --> settled
  settling --> failed
  payout_applying --> failed
```

状态说明：

- `created`：收到创建请求，已生成 `match_id`。
- `joining`：等待参与者加入，人数达到 `min_players` 后可以开奖。
- `settling`：已抢占结算权，正在扣押注、生成骰子结果。
- `payout_applying`：骰子点数已经固定，正在派奖或退款；重试必须复用旧点数。
- `settled`：局已结束，结果固定。
- `failed`：扣款、派奖或退款失败，后续由补偿任务继续处理。

### 表

#### game_dice_matches

关键字段：

- `app_code`
- `match_id`
- `game_id`
- `platform_code`
- `provider_game_id`
- `room_id`
- `region_id`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `round_no`
- `result`
- `status`
- `join_deadline_ms`
- `created_at_ms`
- `updated_at_ms`
- `settled_at_ms`

索引：

- `PRIMARY KEY(app_code, match_id)`
- `idx_game_dice_room_active(app_code, room_id, status, created_at_ms)`
- `idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)`

#### game_dice_participants

一局一个参与者一行。2 人、3 人或 6 人只改变参与者数量，不改主表结构。

关键字段：

- `app_code`
- `match_id`
- `user_id`
- `seat_no`
- `status`
- `stake_coin`
- `dice_points_json`
- `result`
- `payout_coin`
- `debit_order_id`
- `payout_order_id`
- `refund_order_id`
- `balance_after`
- `joined_at_ms`
- `updated_at_ms`

索引：

- `PRIMARY KEY(app_code, match_id, user_id)`
- `UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no)`
- `idx_game_dice_participant_user(app_code, user_id, updated_at_ms)`
- `idx_game_dice_participant_status(app_code, match_id, status, seat_no)`

#### game_dice_round_logs

如果首版只有一局，可以先不建。若后续做多轮、连胜、多人奖池或房间内广播，再补小局日志表。

关键字段：

- `app_code`
- `match_id`
- `round_no`
- `result_json`
- `created_at_ms`

## 骰子接口草案

接口文档只保留地址、参数、返回值和相关 IM。

### 1. 创建骰子局

地址：`POST /api/v1/games/dice/matches`

参数：

- `game_id`
- `room_id`
- `stake_coin`
- `min_players`
- `max_players`

返回值：

- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `participants`
- `server_time_ms`

相关 IM：

- 首版无

### 2. 加入骰子局

地址：`POST /api/v1/games/dice/matches/{match_id}/join`

参数：

- `match_id`

返回值：

- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `participants`
- `server_time_ms`

相关 IM：

- 首版无

### 3. 查询骰子局

地址：`GET /api/v1/games/dice/matches/{match_id}`

参数：

- `match_id`

返回值：

- `match_id`
- `status`
- `min_players`
- `max_players`
- `current_players`
- `stake_coin`
- `result`
- `participants`
- `server_time_ms`

相关 IM：

- 首版无

### 4. 开奖

地址：`POST /api/v1/games/dice/matches/{match_id}/roll`

参数：

- `match_id`

返回值：

- `match_id`
- `status`
- `stake_coin`
- `result`
- `participants`
- `server_time_ms`

相关 IM：

- 首版无
- 如果后续做房间公屏展示，再增加 `dice_match_settled`

## 钱包和订单

自研游戏也要落 `game_orders`，不能直接只写玩法表。

骰子推荐订单：

- 押注扣款：`op_type=debit`，`provider_order_id=dice:{match_id}:{user_id}:debit`
- 派奖加款：`op_type=credit`，`provider_order_id=dice:{match_id}:{user_id}:credit`
- 异常退款：`op_type=refund`，`provider_order_id=dice:{match_id}:{user_id}:refund`

`wallet-service` 的 `CommandId`：

- `game:dice:{match_id}:{user_id}:debit`
- `game:dice:{match_id}:{user_id}:credit`
- `game:dice:{match_id}:{user_id}:refund`

扣款成功后：

- 写 `game_orders`
- 写 `game_outbox`，供统计使用
- 写 `game_level_event_outbox`，供 activity-service 游戏等级使用

派奖和退款是否进等级轨道由产品规则决定，默认只有 `debit` 进游戏等级。

## IM 事件边界

首版骰子不强依赖 IM。客户端发起 HTTP，HTTP 返回结果。

需要 IM 的场景：

- 双人匹配成功
- 倒计时开始
- 双方状态变化
- 结果揭晓
- 对方离开或超时

IM 规则：

- 客户端不能通过 IM 提交游戏动作。
- 客户端动作必须走 HTTP 到 gateway，再转 game-service。
- IM 只承载服务端事实事件。
- IM payload 必须带 `event_id`、`game_code`、`match_id`、`server_time_ms`。
- 客户端按 `event_id` 去重。

## 上线顺序

1. 先补 `api/proto/game/v1/game.proto` 的骰子 RPC。
2. 生成 proto：`make proto`。
3. 在 `gateway-service/internal/transport/http/gameapi` 加 HTTP handler。
4. 在 `game-service` 加 `domain/dice`、`service/dice`、`storage/mysql/dice_repository.go`。
5. 更新 `services/game-service/deploy/mysql/initdb/001_game_service.sql`。
6. 更新 runtime `Migrate`，保证线上库自动补表和索引。
7. 后台把骰子写入 `game_catalog` 和默认 `game_display_rules`。
8. H5 `game/touzi` 通过启动页拿 `game_id/session_id`，局内操作走新增骰子接口。
9. 跑测试：`make proto`、`go test ./services/game-service/... ./services/gateway-service/...`。
10. 本地拉起：`docker compose config`，必要时 `docker compose up -d game-service gateway-service` 验证。

## 不做的事

- 不把骰子局状态放进 `room-service`。
- 不让 H5 直接调 `wallet-service`。
- 不把骰子动作放进第三方 `HandleCallback`。
- 不把所有玩法硬塞进一个通用 `game_matches` 大表。
- 不用 Redis 做唯一事实。Redis 后续只能做短期匹配队列、限流或缓存，MySQL 仍是恢复来源。
