# 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/ 自研游戏纯规则,不能依赖数据库、gRPC、HTTP、钱包 client internal/service/ 自研游戏用例层,负责匹配、状态推进、调用钱包、写 outbox internal/storage/mysql/_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 仍是恢复来源。