475 lines
15 KiB
Markdown
475 lines
15 KiB
Markdown
# 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 仍是恢复来源。
|