--- todos: - id: "define-constants" content: "在 slot-foundation 新增 WalletEventMQ 常量(exchange + routingKey)" status: pending - id: "add-publisher" content: "在 slot-wallet 新增 WalletBetSuccessMqPublisher,封装 {type, uid, data, ts} 载荷组装" status: pending - id: "extend-wallet-dto" content: "slot-wallet / slot-pwa DTO 与 Validate 增加 provider_code、game_code 透传字段" status: pending - id: "hook-bet-success" content: "在 WalletBetWinLogic::bet() commit 后调用 publisher(幂等命中跳过)" status: pending - id: "pwa-pass-game-context" content: "slot-pwa CashLogic 调 wallet 时透传 provider_code(如 pop)与 game_code" status: pending - id: "consumer-bind" content: "各消费服务自建 queue 并 bind topic 模式(wallet.bet.success / wallet.bet.#)" status: pending isProject: false --- # wallet.bet.success 消息体设计方案 ## 背景与定位 当前 `slot-wallet` 下注成功后已有两类 MQ,但都不适合作为**通用钱包事件总线**: | 现有通道 | 载荷 | 局限 | |---|---|---| | `slot_console` / `console_bus` | `{ uid, type:'bet', data:{ source, amount } }` | 仅统计,缺 biz_id / round_id / 资金拆分 | | `slot_activity` / `trial_reward_bus` | `{ type, uid, data, ts }` | 活动专用,且目前由 **slot-pwa** 在钱包 API 返回后投递,非 wallet 权威源 | | `slot_hub` | WebSocket 钱包刷新 | 面向客户端,非业务事件 | `slot-foundation` 文档/测试中的示例 `{"uid":1001,"bet_amount":500}` 过于简陋,无法支撑多服务幂等与金额语义区分。 **新事件定位**:wallet 事务提交后的**权威下注成功事件**,供 activity / console / 统计 / 返水等各自按 Topic 模式订阅(如 `wallet.bet.#`、`wallet.#`)。 ```mermaid sequenceDiagram participant Caller as 调用方_PWA或游戏网关 participant Wallet as slot_wallet participant DB as wallet_log participant Exchange as slot.wallet.event.exchange participant Activity as slot_activity participant Stats as 统计服务 Caller->>Wallet: bet(biz_id, fee, round_id, ...) Wallet->>DB: commit 写入 biz_type=bet Wallet->>Exchange: publish wallet.bet.success Exchange-->>Activity: 各自 queue 绑定 Exchange-->>Stats: 各自 queue 绑定 ``` --- ## 推荐信封结构 沿用 [`TrialRewardMqPublisher`](slot-wallet/app/service/mq/TrialRewardMqPublisher.php) 的约定,便于消费方统一解析: ```json { "type": "wallet.bet.success", "uid": 12345, "ts": 1718092800, "data": { } } ``` - `type`:与 routingKey 一致,便于日志/死信/转发时脱离 routing 元数据也能识别 - `uid`:放信封层,消费方可快速过滤 - `ts`:事件产生时间(Unix 秒),非 DB `created_at` - `data`:业务载荷(见下) --- ## 推荐 `data` 字段(通用可扩展) ### 1. 幂等与追溯(必填) | 字段 | 来源 | 说明 | |---|---|---| | `biz_id` | `WalletUpdateRequestDTO->biz_id` | 业务幂等键,消费方应用 `uid + biz_id` 去重 | | `ledger_id` | `wallet_log.id` | wallet 侧唯一流水号,需查账时可直接定位 | | `round_id` | request | 关联同局 win | | `trace_id` | request(可空) | 链路追踪 | ### 2. 渠道上下文(必填) | 字段 | 来源 | |---|---| | `currency` | request | | `source` | request | | `organization` | request | ### 3. 金额语义(必填,三者不可混用) 下注存在三种路径([`WalletBetWinLogic::bet()`](slot-wallet/app/api/logic/WalletBetWinLogic.php)),金额字段必须区分: | 字段 | 含义 | 计算规则 | |---|---|---| | `bet_amount` | 名义下注额 | 恒等于 request `fee`(最小货币单位,*1000) | | `wallet_deduct_amount` | 实际扣款额 | real/trial = `fee`;FS 代付 = `0` | | `valid_bet_amount` | 计入打码/有效投注额 | real = `betResult.withdraw + betResult.deposit`;trial = `0`;FS 代付 = `fee`(全额计入,与 [`TrialValidBetNotifyService`](slot-wallet/app/service/trial/TrialValidBetNotifyService.php) 一致) | > 消费方按业务选用:`bet_amount` 做流水统计;`valid_bet_amount` 做活动/返水;`wallet_deduct_amount` 做资金变动核对。 ### 4. 下注分类(必填) | 字段 | 取值 | 判定 | |---|---|---| | `bet_mode` | `trial` / `real_money` / `free_spin` | 对应三条执行分支 | 辅助布尔字段(便于过滤,与 `bet_mode` 冗余但实用): - `is_fs_paid_bet`:0/1 - `fs_account_id`:FS 路径时 > 0,否则 0 ### 5. 资金拆分摘要(real_money 路径建议带,trial/FS 可简化) ```json "deduction": { "bonus": 0, "deposit": 3000, "withdraw": 2000, "hit_side": "deposit" } ``` 来源:[`BetService::execute()`](slot-wallet/app/service/wallet/BetService.php) 返回的 `betResult`。供返水、RTP、资金归因等消费方使用。 **不建议**在 MQ 中带完整 `fund_detail` / `remark` JSON(体积大、含 lot_no 等内部细节);需要时可凭 `ledger_id` 回查 `wallet_log`。 ### 6. 游戏身份(多上游平台场景,建议必填) slot-pwa 已有完整游戏映射链(见下),但 **slot-wallet 不查 g_game 表**,因此游戏身份必须由调用方(通常是 slot-pwa)在下注请求中透传,wallet 原样写入 MQ。 ```mermaid flowchart LR subgraph pwa [slot_pwa] ProviderCode["provider_code 如 pop"] ExternalCode["vendor_game_id 即 external_game_code"] GameCode["game_code 本平台编码"] end subgraph mapping [g_game_platform_mapping] MapRow["platform_id + external_game_code"] end subgraph game [g_game] GameRow["game_code 全局唯一"] end ProviderCode --> MapRow ExternalCode --> MapRow MapRow --> GameRow GameRow --> GameCode ``` | 字段 | 对应 slot-pwa 概念 | 说明 | |---|---|---| | `provider_code` | `GPlatformModel.code` / `GameRoundModel.provider_code` | 上游平台编码,如 `pop`;多平台并存时**必填** | | `game_code` | `GGameModel.game_code` / `PopTransferCallbackContext::gameCode()` | 本平台全局唯一游戏编码;消费方可直接关联 `g_game` | | `vendor_game_id` | `GGamePlatformMappingModel.external_game_code` | 上游原始游戏 ID(POP 回调 `gameid`);与 `provider_code` 组合可唯一定位 mapping | | `game_category` | `WalletTrialGameContextResolver::resolveGameCategory()` | 如 `SLOTS`;默认 `SLOTS` | **唯一定位规则**(与 [`PopGameResolveService`](slot-pwa/app/service/game/PopGameResolveService.php) 一致): - 主键语义:`provider_code` + `vendor_game_id` → `g_game_platform_mapping` → `g_game` - 消费方若只需本平台游戏:读 `game_code` 即可 - 消费方若需区分上游来源:用 `provider_code` + `vendor_game_id` - 三者齐全时消费方可交叉校验,避免 mapping 变更导致歧义 当前 gap:[`CashLogic::invokeWalletBetOrWin()`](slot-pwa/app/pop/logic/CashLogic.php) 已解析出 `boundGame` 与 `providerCode()`,但调 wallet 时**仅传了** `vendor_game_id` / `game_category`,**未传** `provider_code` / `game_code`——落地时需补齐。 wallet 层**不持久化**游戏字段到 `wallet_log`,必须在 commit 后从 request 即时写入 MQ。 --- ## 完整示例 ### 真实资金下注 ```json { "type": "wallet.bet.success", "uid": 12345, "ts": 1718092800, "data": { "biz_id": "pg_tx_20240611_001", "ledger_id": 987654321, "currency": "USD", "round_id": "round_abc123", "source": "myslot", "organization": 1001, "trace_id": "trace_xyz", "bet_amount": 5000, "wallet_deduct_amount": 5000, "valid_bet_amount": 5000, "bet_mode": "real_money", "is_fs_paid_bet": 0, "fs_account_id": 0, "provider_code": "pop", "game_code": "pop_sweet_bonanza", "game_category": "SLOTS", "vendor_game_id": "pg_soft_001", "deduction": { "bonus": 0, "deposit": 3000, "withdraw": 2000, "hit_side": "deposit" } } } ``` ### FS 代付下注 ```json { "type": "wallet.bet.success", "uid": 12345, "ts": 1718092800, "data": { "biz_id": "pg_tx_fs_001", "ledger_id": 987654322, "currency": "USD", "round_id": "round_fs_001", "source": "myslot", "organization": 1001, "trace_id": "", "bet_amount": 2000, "wallet_deduct_amount": 0, "valid_bet_amount": 2000, "bet_mode": "free_spin", "is_fs_paid_bet": 1, "fs_account_id": 5566, "provider_code": "pop", "game_code": "pop_sweet_bonanza", "game_category": "SLOTS", "vendor_game_id": "pg_soft_001", "deduction": { "bonus": 0, "deposit": 0, "withdraw": 0, "hit_side": "" } } } ``` ### 试玩期下注 ```json { "type": "wallet.bet.success", "uid": 12345, "ts": 1718092800, "data": { "biz_id": "trial_bet_001", "ledger_id": 987654323, "currency": "USD", "round_id": "round_trial_001", "source": "myslot", "organization": 1001, "trace_id": "", "bet_amount": 1000, "wallet_deduct_amount": 1000, "valid_bet_amount": 0, "bet_mode": "trial", "is_fs_paid_bet": 0, "fs_account_id": 0, "provider_code": "pop", "game_code": "pop_trial_slots", "game_category": "SLOTS", "vendor_game_id": "pg_trial_001", "deduction": { "bonus": 1000, "deposit": 0, "withdraw": 0, "hit_side": "bonus" } } } ``` --- ## 关键行为约定 1. **仅首次成功投递**:幂等命中(`findByBizIdAndType` 已有记录)时**不**发 MQ,避免重复消费。 2. **投递时机**:`commit()` 之后,与现有 `sendConsoleBus('bet')` 同级([`WalletBetWinLogic` L106-108](slot-wallet/app/api/logic/WalletBetWinLogic.php))。 3. **失败策略**:MQ 发送失败只记 warning 日志,**不回滚**已提交的钱包事务(与 `TrialRewardMqPublisher` 一致)。 4. **消费方幂等**:建议以 `uid + biz_id` 或 `ledger_id` 做唯一约束/去重。 5. **与 trial_reward_bus 关系**:短期可并存;长期 activity 可改为订阅 `wallet.bet.success` 并按 `bet_mode` / `valid_bet_amount` 自行路由,逐步去掉 PWA 侧重复投递。 --- ## 若落地实现(后续步骤) 1. **slot-foundation**:新增 [`WalletEventMQ`](slot-foundation/src/Constants/MQ/) 常量类(`EXCHANGE = slot.wallet.event.exchange`、`ROUTING_KEY_BET_SUCCESS = wallet.bet.success`)。 2. **slot-wallet**: - `WalletUpdateRequestDTO` / Validate 增加 `provider_code`、`game_code`(字符串,可空但游戏下注场景应由调用方传入) - 新增 `WalletBetSuccessMqPublisher`(或通用 `WalletEventMqPublisher`) - 在 `WalletBetWinLogic::bet()` commit 后组装上述 `data` 并 publish - 抽取 `resolveBetMode()` / `resolveValidBetAmount()` 私有方法,避免金额语义散落 3. **slot-pwa**(游戏上下文来源): - `WalletWinOrBetRequestDTO` 增加 `provider_code`、`game_code` - [`CashLogic::invokeWalletBetOrWin()`](slot-pwa/app/pop/logic/CashLogic.php) 从 `transferContext->providerCode()` 与 `boundGame->game_code` 透传 - [`WalletService::appendTrialGameContext()`](slot-pwa/app/service/WalletService.php) 非空时写入请求体 - 后续新上游平台(非 pop)沿用同一字段,平台 code 取自各自 `providerCode()` 实现 4. **消费方**:各服务自建 queue + bind(如 `wallet.bet.success` 或 `wallet.bet.#`),按 `provider_code` / `game_code` 过滤统计。 --- ## 设计原则小结 - **信封统一**:`{ type, uid, data, ts }`,与 activity 事件一致 - **金额三分**:`bet_amount` / `wallet_deduct_amount` / `valid_bet_amount`,避免 FS、trial、real 混用 - **自洽但不臃肿**:含消费方 80% 场景所需字段;深度明细靠 `ledger_id` 回查 - **游戏三元组**:`provider_code` + `vendor_game_id` + `game_code`,对齐 slot-pwa 映射,支持多上游平台 - **权威源在 wallet**:从 PWA 侧活动通知逐步收敛到 wallet 发出的事件;游戏身份由 PWA 解析后透传