hyapp-server/docs/game-service游戏结构设计.md
2026-06-11 01:02:16 +08:00

475 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-serviceIM 只下发事件 |
## 自研游戏统一链路
```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。客户端发起 HTTPHTTP 返回结果。
需要 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 仍是恢复来源。