15 KiB
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.ListGamesGameAppService.ListRecentGamesGameAppService.GetBridgeScriptGameAppService.LaunchGameGameCallbackService.HandleCallbackGameAdminService管理平台和目录GameCronService.ProcessLevelEventOutboxBatch
现有核心表:
game_platforms:第三方平台配置game_catalog:App 看到的游戏目录game_display_rules:按场景、区域、语言和客户端平台控制展示game_launch_sessions:H5 启动会话game_orders:第三方或游戏结算引起的钱包订单game_callback_logs:第三方回调审计game_level_event_outbox:游戏消耗投递 activity-servicegame_outbox:游戏订单事实投递 RocketMQ,给 statistics-service 等下游消费
分层结构
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 只下发事件 |
自研游戏统一链路
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_codeevent_idevent_typegame_codematch_idtarget_user_idpayload_jsonstatusworker_idlock_until_msretry_countnext_retry_at_mslast_errorcreated_at_msupdated_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。
写入顺序:
- 先在 MySQL 事务里锁定
game_dice_matches。 - 创建/加入只写
game_dice_participants和局状态。 - 开奖阶段用钱包幂等键写
game_orders,再保存点数、派奖和局结果。 - MySQL 事实提交后刷新 Redis 快照。
- Redis 刷新失败只记录日志和重试,不回滚已提交的 MySQL 事实。
骰子游戏首版结构
首版按多人可扩展结构设计。当前实现要求至少 2 人,默认最多 2 人,硬上限 6 人;3 人局只增加参与者行,不改表。H5 只负责展示和发起操作,随机、扣款、派奖和结果都由 game-service 完成。
当前骰子规则是单局胜场制。每个参与者每轮只扔 1 颗骰子,服务端把这个点数写入 dice_points[0],点数高的一方获胜。同点不是终局,不退本金、不派奖,H5 展示点数 2 秒后继续调用 roll,同一个 match_id 会一直重投到分出胜负。
模块
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
创建局、参与人行锁读取、状态推进、订单关联、查询
状态机
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_codematch_idgame_idplatform_codeprovider_game_idroom_idregion_idmin_playersmax_playerscurrent_playersstake_coinround_noresultstatusjoin_deadline_mscreated_at_msupdated_at_mssettled_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_codematch_iduser_idseat_nostatusstake_coindice_points_jsonresultpayout_coindebit_order_idpayout_order_idrefund_order_idbalance_afterjoined_at_msupdated_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_codematch_idround_noresult_jsoncreated_at_ms
骰子接口草案
接口文档只保留地址、参数、返回值和相关 IM。
1. 创建骰子局
地址:POST /api/v1/games/dice/matches
参数:
game_idroom_idstake_coinmin_playersmax_players
返回值:
match_idstatusmin_playersmax_playerscurrent_playersstake_coinparticipantsserver_time_ms
相关 IM:
- 首版无
2. 加入骰子局
地址:POST /api/v1/games/dice/matches/{match_id}/join
参数:
match_id
返回值:
match_idstatusmin_playersmax_playerscurrent_playersstake_coinparticipantsserver_time_ms
相关 IM:
- 首版无
3. 查询骰子局
地址:GET /api/v1/games/dice/matches/{match_id}
参数:
match_id
返回值:
match_idstatusmin_playersmax_playerscurrent_playersstake_coinresultparticipantsserver_time_ms
相关 IM:
- 首版无
4. 开奖
地址:POST /api/v1/games/dice/matches/{match_id}/roll
参数:
match_id
返回值:
match_idstatusstake_coinresultparticipantsserver_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}:debitgame:dice:{match_id}:{user_id}:creditgame: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去重。
上线顺序
- 先补
api/proto/game/v1/game.proto的骰子 RPC。 - 生成 proto:
make proto。 - 在
gateway-service/internal/transport/http/gameapi加 HTTP handler。 - 在
game-service加domain/dice、service/dice、storage/mysql/dice_repository.go。 - 更新
services/game-service/deploy/mysql/initdb/001_game_service.sql。 - 更新 runtime
Migrate,保证线上库自动补表和索引。 - 后台把骰子写入
game_catalog和默认game_display_rules。 - H5
game/touzi通过启动页拿game_id/session_id,局内操作走新增骰子接口。 - 跑测试:
make proto、go test ./services/game-service/... ./services/gateway-service/...。 - 本地拉起:
docker compose config,必要时docker compose up -d game-service gateway-service验证。
不做的事
- 不把骰子局状态放进
room-service。 - 不让 H5 直接调
wallet-service。 - 不把骰子动作放进第三方
HandleCallback。 - 不把所有玩法硬塞进一个通用
game_matches大表。 - 不用 Redis 做唯一事实。Redis 后续只能做短期匹配队列、限流或缓存,MySQL 仍是恢复来源。