# Wallet Service Architecture 本文定义钱包服务的目标架构、账务模型、核心流程和落地顺序。当前 `wallet-service` 只有单币种 `coin` 扣费能力,新需求需要升级为多资产账户、外部支付订单、币商充值、主播奖励和提现审核的一体化账务边界。 资源、资源组、用户资源赠送和礼物从资源库选择的边界见 [Resource And Resource Group Architecture](./资源组与礼物架构.md)。本文只保留钱包账务和礼物价格结算边界。 ## Goals - `wallet-service` 是所有用户资产余额、流水、幂等和提现冻结的唯一 owner。 - `room-service` 只同步调用钱包完成送礼扣费和主播积分入账,不直接修改余额。 - MQ 只消费钱包 outbox 事件,不作为余额事实来源。 - Redis 只能做读缓存、风控计数或短期状态,不能作为余额、充值或提现恢复来源。 - 所有资金和虚拟资产使用整数最小单位,不能使用浮点数。 - 钱包按 `app_code` 隔离资产、交易、幂等、礼物价格和 outbox;不同 App 下相同 `user_id` 或 `command_id` 不能互相影响。 ## Asset Model | Asset | Meaning | Spendable | Withdrawable | Owner | | --- | --- | --- | --- | --- | | `COIN` | 金币,用户送礼消费资产 | yes | no | wallet-service | | `COIN_SELLER_COIN` | 币商专用金币库存,只能转给玩家形成普通 `COIN` | yes, seller transfer only | no | wallet-service | | `DIAMOND` | 钻石,不可直接消费,只能按政策兑换金币或美元余额 | no | no | wallet-service | | `GIFT_POINT` | 主播礼物积分,达到政策后结算美元奖励 | no | no | wallet-service | | `USD_BALANCE` | 主播可提现美元余额,建议单位为 cent 或 micro cent | no | yes | wallet-service | 钻石和积分都不是直接消费资产。钻石兑换金币或美元余额时必须落兑换订单,记录政策版本、汇率和结果。主播积分达到政策后,由结算任务发起美元余额奖励入账,用户提现只能提现 `USD_BALANCE`。 资源组可以通过资源域的 `wallet_asset` 组成员直接给用户发放 `COIN` 或 `DIAMOND`,但这仍然是钱包入账流水,不写用户资源权益;钻石后续兑换仍必须走独立兑换订单。 ## Current Implementation Snapshot | Capability | Status | Current Evidence | Gap | | --- | --- | --- | --- | | `DebitGift` RPC | `DONE` | `DebitGift` 只接收礼物 ID、数量、房间区域和可选价格版本,返回 `transaction_id/coin_spent/gift_point_added/heat_value/price_version` | 充值、兑换、提现仍需新增 RPC 和状态机 | | 多资产账户 | `PARTIAL` | `wallet_accounts(app_code,user_id,asset_type,available_amount,frozen_amount,version)` | 已能表达冻结余额;提现冻结/解冻/出账命令未落地 | | 交易和分录 | `DONE` | 交易写 `wallet_transactions`、`wallet_entries`,不保留旧流水表 | 充值、兑换、提现的分录类型仍需补齐 | | 命令幂等 | `DONE` | `wallet_transactions.command_id` + `request_hash` | 重复 provider receipt 也要幂等 | | 余额不足保护 | `DONE` | MySQL 事务锁 sender account,余额不足失败 | 需要并发压测覆盖多资产和冻结余额 | | 礼物价格 | `DONE` | `wallet_gift_prices` 提供服务端 `coin_price/gift_point_amount/heat_value`,客户端不提交礼物单价 | 礼物资源、资源组和赠送边界见资源架构文档 | | 充值 | `PARTIAL` | 币商转账已写 `wallet_recharge_records`,provider order 未实现 | Apple、Google、线下订单和 provider 校验仍需补齐 | | 币商转金币 | `DONE` | `TransferCoinFromSeller` 扣 `COIN_SELLER_COIN`、加玩家 `COIN`,并按区域充值政策记录美元充值金额 | 限额、线下订单和风控审计仍需补齐 | | 钻石兑换 | `TODO` | 无 diamond exchange order | 需要政策快照、兑换状态机和原子出入账 | | 主播积分/奖励 | `PARTIAL` | 送礼实时给收礼人加 `GIFT_POINT` | 周期结算任务和 `USD_BALANCE` 奖励入账未落地 | | 提现 | `TODO` | 无 withdraw request | 需要冻结、审核、人工打款、失败回滚 | | 钱包 outbox | `PARTIAL` | `wallet_outbox` 已和交易同事务写入 `WalletBalanceChanged/WalletGiftDebited/WalletCoinSellerTransferred/WalletRechargeRecorded` | outbox worker 和 MQ 投递适配未落地 | 这张表是需求边界,不是上线状态承诺。后续开发必须先补账务事实层,再补 HTTP 管理入口和活动联动。 ## Requirement Scope ### P0: Gift Billing Foundation - `DebitGift` 只接收服务端定价所需字段,不保留历史兼容字段。 - 钱包按服务端礼物价格结算 `coin_spent`、`gift_point_added` 和 `heat_value`,客户端不能提交礼物单价。 - 送礼扣费必须校验 `gift_configs` 和 `gift_config_regions`,只允许房间 `visible_region_id` 或 GLOBAL 区域可用的 active 礼物进入账务。 - 同一个事务内完成 sender `COIN` 扣减、target `GIFT_POINT` 入账、交易记录、分录和 outbox。 - `room-service` 只消费钱包返回的已结算值更新房间热度、本地榜和房间 outbox。 - 扣费失败时 `room-service` 不进入 Room Cell 状态变更。 ### P1: Balance And Admin Operations - 增加多资产余额查询,客户端只能看到自己的余额,后台可以按权限查询用户资产和流水。 - 增加后台手动充值、扣减和调账命令,所有操作必须记录操作人、原因、凭证和审批来源。 - 增加账务对账查询:按用户、资产、交易、外部订单和时间窗口查询 transaction/entry。 - 任何后台命令都必须有独立 `command_id`,重复提交返回原结果,不允许重复入账。 ### P2: Provider Recharge - Apple/Google 充值必须落 `payment_orders`,订单状态和账务入账不能只依赖客户端回调。 - provider receipt、transaction id、purchase token 必须全局唯一,重复通知只能返回原订单结果。 - provider 校验失败不能产生资产分录,校验成功和资产入账必须在同一个 MySQL 事务提交。 - 商品配置必须是服务端资产包配置,客户端传入的商品 ID 只能用于匹配,不决定到账金额。 ### P3: Coin Seller And Exchange - 币商库存是币商自己的 `COIN_SELLER_COIN` 账户余额,币商给用户转币必须先扣币商再加用户。 - 币商转账按双方同一区域的 active 充值政策换算充值金额,例如 `coin_amount=80000`、`usd_minor_amount=100` 表示 80000 金币记 1 美元充值。 - 充值金额使用美元最小单位 `usd_minor_amount` 入账;金币数量不能精确换算到最小单位时直接拒绝,避免隐式四舍五入。 - 币商转账需要限额、风控状态、线下订单号、目标用户和审计日志。 - 钻石兑换必须生成兑换订单,记录政策版本和汇率快照,兑换成功原子扣 `DIAMOND` 并加目标资产。 - 兑换失败不能出现单边扣减,重复兑换命令必须返回同一兑换订单结果。 ### P4: Anchor Reward And Withdraw - 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。 - 奖励结算生成 `anchor_reward_settlements`,确认后加主播 `USD_BALANCE`。 - 提现申请必须先冻结 `USD_BALANCE`,审核拒绝解冻,审核通过后进入人工打款流程。 - 人工打款完成后从冻结余额出账;打款失败回到可重试状态,不能自动解冻后丢失审核上下文。 ## Component Diagram ```mermaid graph LR Client["Client"] --> Gateway["gateway-service"] Gateway --> Room["room-service"] Gateway --> Wallet["wallet-service"] Gateway --> Admin["admin/ops API"] Room -->|"DebitGift gRPC"| Wallet Admin -->|"manual recharge / withdraw review"| Wallet Merchant["coin merchant"] -->|"merchant transfer API"| Gateway Apple["Apple IAP"] -->|"receipt / callback"| Gateway Google["Google Play Billing"] -->|"receipt / callback"| Gateway Wallet --> MySQL[("wallet MySQL")] Wallet --> Outbox[("wallet_outbox")] Outbox --> MQ["MQ / event bus"] MQ --> Activity["activity-service / settlement jobs"] MQ --> BI["risk / audit / analytics"] Wallet --> RoomEvent["billing result"] Room --> TencentIM["Tencent IM room message"] ``` ## Ledger Design 钱包不要继续使用“单账户余额表 + 单条流水”承载所有场景。目标模型是账户表记录余额,交易表记录业务命令,分录表记录每个账户的资产变化。 核心表建议: ```sql wallet_accounts( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', user_id BIGINT NOT NULL, asset_type VARCHAR(32) NOT NULL, available_amount BIGINT NOT NULL, frozen_amount BIGINT NOT NULL, version BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, user_id, asset_type) ); wallet_transactions( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', transaction_id VARCHAR(96) NOT NULL, command_id VARCHAR(128) NOT NULL, biz_type VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, request_hash VARCHAR(128) NOT NULL, external_ref VARCHAR(128) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, transaction_id), UNIQUE KEY uk_wallet_tx_command (app_code, command_id) ); wallet_entries( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, transaction_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, asset_type VARCHAR(32) NOT NULL, available_delta BIGINT NOT NULL, frozen_delta BIGINT NOT NULL, available_after BIGINT NOT NULL, frozen_after BIGINT NOT NULL, counterparty_user_id BIGINT NOT NULL DEFAULT 0, room_id VARCHAR(96) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, KEY idx_wallet_entries_user_time (app_code, user_id, created_at_ms), KEY idx_wallet_entries_tx (app_code, transaction_id) ); ``` 交易和分录的核心约束: - `wallet_transactions.command_id` 是钱包命令幂等键,同一个 `app_code + command_id` 重复提交必须返回同一结果。 - `wallet_transactions.request_hash` 覆盖业务语义,同一个 `app_code + command_id` 携带不同 payload 必须返回冲突。 - `wallet_entries` 是资产事实的审计分录,原则上只追加不修改;余额修正也要通过新交易和新分录表达。 - `wallet_entries.available_delta` 和 `frozen_delta` 必须同时表达一笔资产变化,提现冻结是 available 负数、frozen 正数,打款完成是 frozen 负数。 - `wallet_accounts.version` 用于并发更新保护,写路径仍以 MySQL 行锁和事务为准。 - `available_amount + frozen_amount` 是用户某资产的总负债,不能出现负数,不能由 Redis 恢复。 支付、兑换、提现使用独立状态表承载业务状态机: - `payment_orders`: Apple、Google、运营给币商发币和线下充值记录。 - `coin_seller_transfers`: 币商给用户转金币的交易申请和结果;当前实现先用 `wallet_transactions.biz_type=coin_seller_transfer` 承载结果。 - `diamond_exchange_orders`: 钻石兑换金币或美元余额的政策快照。 - `anchor_reward_settlements`: 主播积分达到政策后的美元奖励结算单。 - `withdraw_requests`: 主播提现申请、审核、驳回、人工打款和完成状态。 - `wallet_outbox`: 余额变化、充值成功、提现状态变化等事件投递源。 业务状态表必须把外部唯一键落到数据库约束里。Apple/Google 使用 provider transaction id 或 purchase token 唯一约束,币商转账使用 coin seller order id + seller user id 唯一约束,提现使用 withdraw request id 唯一约束。 礼物价格需要单独的服务端账务配置,不能由房间命令携带价格决定: ```sql wallet_gift_prices( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', gift_id VARCHAR(96) NOT NULL, price_version VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, coin_price BIGINT NOT NULL, gift_point_amount BIGINT NOT NULL, heat_value BIGINT NOT NULL, effective_at_ms BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, gift_id, price_version), KEY idx_wallet_gift_prices_effective (app_code, gift_id, status, effective_at_ms) ); ``` `wallet_gift_prices` 只承载账务结算字段。礼物名称、图标、动画、排序和客户端资源必须通过资源库和 `gift_configs` 配置,不能把资源字段塞进账务价格表;钱包落账时必须记录 `gift_id`、`price_version`、`coin_price`、`gift_point_amount` 和 `heat_value` 快照。 币商转账的充值口径需要单独记录区域政策和充值事实: ```sql wallet_recharge_policies( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', policy_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, region_id BIGINT NOT NULL, policy_version VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, currency_code VARCHAR(3) NOT NULL DEFAULT 'USD', coin_amount BIGINT NOT NULL, usd_minor_amount BIGINT NOT NULL, effective_from_ms BIGINT NOT NULL, effective_to_ms BIGINT NULL, UNIQUE KEY uk_wallet_recharge_policy_version (app_code, region_id, policy_version) ); wallet_recharge_records( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', transaction_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, seller_user_id BIGINT NOT NULL, seller_region_id BIGINT NOT NULL, target_region_id BIGINT NOT NULL, policy_id BIGINT NOT NULL, policy_version VARCHAR(64) NOT NULL, currency_code VARCHAR(3) NOT NULL, coin_amount BIGINT NOT NULL, usd_minor_amount BIGINT NOT NULL, exchange_coin_amount BIGINT NOT NULL, exchange_usd_minor_amount BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, transaction_id) ); ``` `wallet_recharge_records.usd_minor_amount` 是用户充值额的权威统计口径;用户不会因此获得 `USD_BALANCE`。政策字段必须保存快照,后续修改区域汇率不能重算历史充值。 ## Send Gift Flow ```mermaid sequenceDiagram participant C as Client participant G as gateway-service participant R as room-service participant W as wallet-service participant DB as wallet MySQL participant IM as Tencent IM C->>G: send gift G->>R: SendGift(command_id, room_id, gift_id, count) R->>R: Room Cell validates room/user/target R->>W: DebitGift(command_id, payer, anchor, gift) W->>DB: tx lock payer COIN account W->>DB: debit COIN, credit GIFT_POINT, write tx/entries/outbox W-->>R: receipt, coin_spent, point_added, balance_after R->>R: commit heat/rank/contribution/snapshot/outbox R-->>G: room snapshot R->>IM: gift room message ``` `DebitGift` 应该由钱包按服务端礼物配置计算最终金币扣费和主播积分。客户端不提交礼物单价,`room-service` 只能使用钱包返回的已结算值;房间恢复也只使用 command log 中保存的 `heat_value`,不能重新推导价格。 送礼账务要求: - `sender_user_id` 和 `target_user_id` 必须不同;如果产品允许给自己送礼,需要单独产品规则和风控开关。 - `gift_count` 必须设置单次上限,防止大整数溢出和单请求绕过风控。 - `coin_spent = coin_price * gift_count`,计算前要检查乘法溢出。 - `gift_point_added = gift_point_amount * gift_count`,`heat_value = heat_value_per_gift * gift_count`。 - 余额不足时不能写 `wallet_transactions` 的成功状态,不能写 `wallet_entries`,也不能写 outbox。 - 扣费成功后必须返回 `billing_receipt_id`、`transaction_id`、`coin_spent`、`gift_point_added`、`heat_value`、`price_version` 和 sender `COIN` 账后余额。 ## Recharge Flows Apple 和 Google: 1. 客户端创建或购买商品,商品映射到固定资产包。 2. 客户端提交 provider receipt 或 purchase token。 3. `wallet-service` 调 provider 校验接口,确认未消费、金额、商品、环境和用户绑定。 4. MySQL 事务内写 `payment_orders`、`wallet_transactions`、`wallet_entries` 和 `wallet_outbox`。 5. 同一个 provider transaction id 必须全局唯一,重复回调只能返回原结果。 币商: 1. 币商私下给平台转账后,运营后台创建币商库存入账,审核通过后给币商账户发放 `COIN_SELLER_COIN`。 2. 币商没有可提现余额入账,平台给币商发放的是可转给用户的金币库存。 3. 用户私下给币商转账后,币商发起转金币命令,钱包在一个事务中扣币商 `COIN_SELLER_COIN`,加用户 `COIN`。 4. 币商给用户转多少 `COIN`,就代表这次用户通过币商完成了多少金币充值。 5. 这类交易 `biz_type=coin_seller_transfer`,同时记录币商 ID、目标用户、金额、原因和操作来源。 6. 币商不能直接改用户余额,所有发币和转币都必须走钱包命令和审计流水。 运营线下充值: 1. 用户线下给平台转账后,运营后台创建 `manual_recharge`。 2. 审核通过后钱包给用户加 `COIN`,并记录操作人、凭证、金额和备注。 3. 手动充值必须有权限、二次确认和审计日志。 充值订单状态机: ```mermaid stateDiagram-v2 [*] --> created created --> verifying verifying --> verified verifying --> verify_failed verified --> credited credited --> [*] verify_failed --> [*] ``` `credited` 是唯一代表资产已到账的终态。`verified` 只代表 provider 或后台审核通过,不代表余额已更新;从 `verified` 到 `credited` 必须和 `wallet_transactions`、`wallet_entries`、`wallet_outbox` 同事务提交。 ## Diamond Exchange ```mermaid flowchart LR A["User DIAMOND account"] -->|"debit diamonds"| X["diamond_exchange_order"] X -->|"policy: diamond to coin"| C["User COIN account"] X -->|"policy: diamond to USD"| U["User USD_BALANCE account"] ``` 钻石兑换必须冻结政策版本。订单里至少记录 `from_diamond_amount`、`to_asset_type`、`to_amount`、`policy_id`、`rate_snapshot` 和 `status`。兑换成功后写分录和 outbox;兑换失败不能出现钻石扣了但目标资产没到账。 ## Anchor Reward And Withdraw 主播通过礼物获得 `GIFT_POINT`。积分政策不要写死在送礼链路里,建议由结算任务按周期计算奖励: 1. 送礼时钱包给主播增加 `GIFT_POINT`。 2. 结算任务读取积分和政策,生成 `anchor_reward_settlements`。 3. 结算单确认后调用钱包 `CreditRewardBalance`,增加主播 `USD_BALANCE`。 4. 主播提现时先冻结 `USD_BALANCE`,审核拒绝解冻,审核通过后等待人工转账。 5. 人工转账完成后把冻结余额出账,状态改为 `paid`。 提现状态机: ```mermaid stateDiagram-v2 [*] --> pending_review pending_review --> rejected rejected --> refunded pending_review --> approved approved --> paying paying --> paid paying --> pay_failed pay_failed --> approved ``` 提现申请时必须从 `available_amount` 转到 `frozen_amount`,不能只写一个待审核单。否则审核期间用户可以重复提现同一笔余额。 ## RPC Surface 当前开发阶段直接使用新的 wallet RPC 契约: - `GetBalances`: 查询用户多资产余额。 - `DebitGift`: 扣金币、加主播积分,返回已结算值。 - `CreatePaymentOrder`: 创建 Apple/Google 商品订单。 - `ConfirmProviderPayment`: 校验 provider receipt 并入账。 - `AdminCreditAsset`: 运营手动充值或调账,只开放后台。 - `TransferCoinFromSeller`: 币商转金币给用户。 - `ExchangeDiamond`: 钻石兑换金币或美元余额。 - `CreditRewardBalance`: 结算任务给主播发美元余额奖励。 - `CreateWithdrawRequest`: 主播提现申请并冻结余额。 - `ReviewWithdrawRequest`: 后台审核提现。 - `MarkWithdrawPaid`: 人工打款后确认出账。 外部 HTTP 入口仍在 `gateway-service`,内部统一 gRPC + protobuf。当前处于开发阶段,不做历史兼容保留;修改 `api/proto` 后必须运行 `make proto` 和 `go test ./...`。 ### RPC Field Requirements `DebitGift` 字段要求: - Request: `price_version` 可选;为空时钱包选择当前生效价格;传入时必须命中有效价格。 - Response: `transaction_id`,用于内部账务查询。 - Response: `coin_spent`,实际扣减的 `COIN`。 - Response: `gift_point_added`,实际增加给收礼人的 `GIFT_POINT`。 - Response: `heat_value`,room-service 更新房间热度的唯一可信值。 - Response: `price_version`,这次账务使用的礼物价格版本。 - Response: `balance_after`,语义限定为 sender `COIN available_amount`。 新 RPC 的共同字段要求: - Request 必须包含 `command_id`,后台命令还必须包含 `operator_user_id`、`reason`、`evidence_ref`。 - Response 必须包含稳定业务 ID,例如 `transaction_id`、`payment_order_id`、`withdraw_request_id`。 - 查询 RPC 可以不需要 `command_id`,但必须透传 gateway 的 `request_id` 用于追踪。 - 错误码必须能被 gateway 映射成稳定 HTTP envelope,不能只返回自由文本。 `TransferCoinFromSeller` 字段要求: - Request: `seller_region_id` 和 `target_region_id` 来自 user-service 用户主数据,客户端不能提交。 - Request: 当前版本要求币商和目标用户同区域;跨区转账必须先设计明确的政策选择规则。 - Response: `recharge_usd_minor` 是本次转账按区域政策折算的充值美元最小单位金额。 - Response: `recharge_policy_id`、`recharge_policy_version`、`recharge_policy_coin_amount` 和 `recharge_policy_usd_minor_amount` 是冻结在本次充值记录里的政策快照。 ## HTTP And Admin Requirements 外部 HTTP 仍只放在 `gateway-service`,wallet-service 不直接暴露公网 HTTP。 客户端接口: - `GET /api/v1/wallet/balances`: 查询当前用户多资产余额。 - `GET /api/v1/wallet/transactions`: 查询当前用户流水,支持 asset、biz_type、时间和游标分页。 - `POST /api/v1/wallet/payments/apple/confirm`: 提交 Apple receipt 或 transaction id。 - `POST /api/v1/wallet/payments/google/confirm`: 提交 Google purchase token。 - `POST /api/v1/wallet/diamond/exchange`: 发起钻石兑换。 - `POST /api/v1/wallet/withdrawals`: 主播发起提现。 - `GET /api/v1/wallet/withdrawals`: 查询提现记录。 后台接口: - 用户资产查询和流水查询。 - 手动充值、扣减和调账。 - 币商账户开通、禁用、库存发放、转账查询。 - 提现审核、打款标记、打款失败重试。 - 礼物账务价格配置和上下架;礼物资源、资源组和赠送入口按资源架构文档实现。 - 兑换政策、奖励政策和限额配置。 后台所有写接口必须记录操作人、来源 IP、原因、工单或凭证引用。没有审计字段的后台写操作不允许落地。 ## Error Semantics 钱包错误要区分“用户可处理”和“系统需重试”: | Error | Meaning | Retry | | --- | --- | --- | | `INSUFFICIENT_BALANCE` | 可用余额不足 | no | | `ACCOUNT_FROZEN` | 账户被风控冻结 | no | | `GIFT_NOT_FOUND` | 礼物账务配置不存在 | no | | `GIFT_OFF_SHELF` | 礼物不可购买 | no | | `REQUEST_CONFLICT` | 同 `command_id` 不同 payload | no | | `PAYMENT_VERIFY_FAILED` | provider 校验失败 | no | | `PROVIDER_DUPLICATE` | provider 交易已处理 | return original | | `WITHDRAW_AMOUNT_INVALID` | 提现金额或门槛不满足 | no | | `LEDGER_BUSY` | 同账户并发冲突或锁等待超时 | yes | | `LEDGER_INTERNAL` | 数据库或内部未知错误 | yes | `room-service` 调 `DebitGift` 时,只能在明确可重试错误上按上层策略重试。余额不足、礼物下架、请求冲突不能重试成重复扣费。 ## Outbox Events `wallet_outbox` 必须和账务交易同事务写入。事件消费者失败不能回滚账务事实,只能通过 outbox worker 补投。 建议事件: - `WalletBalanceChanged`: 任意资产 available/frozen 变化。 - `WalletGiftDebited`: 送礼扣费成功,包含 sender、target、room、gift、price_version、coin_spent、gift_point_added。 - `PaymentCredited`: provider 或后台充值到账。 - `WalletCoinSellerTransferred`: 币商给用户转金币成功。 - `WalletRechargeRecorded`: 用户充值口径记录成功,包含 `coin_amount`、`usd_minor_amount`、`policy_id` 和 `policy_version`。 - `DiamondExchanged`: 钻石兑换成功。 - `RewardBalanceCredited`: 主播奖励美元余额入账。 - `WithdrawFrozen`: 提现申请冻结成功。 - `WithdrawPaid`: 人工打款完成并出账。 - `WithdrawRejected`: 审核拒绝并解冻。 事件 payload 必须包含 `event_id`、`transaction_id`、`command_id`、`user_id`、`asset_type`、`available_delta`、`frozen_delta`、`created_at_ms`。消费者必须按 `event_id` 或 `transaction_id + event_type` 幂等。 ## Reconciliation And Observability - 每日对账任务按 `wallet_entries` 汇总校验 `wallet_accounts.available_amount/frozen_amount`。 - Provider 充值需要按 provider 后台交易和本地 `payment_orders` 做差异对账。 - 提现需要按 `withdraw_requests` 状态、冻结余额和人工打款记录做对账。 - 关键指标:扣费成功率、余额不足数、幂等命中数、请求冲突数、provider 校验失败数、outbox pending 数、outbox 重试次数、提现冻结金额。 - 关键日志必须带 `request_id`、`command_id`、`transaction_id`、`user_id`、`biz_type`,但不能输出 provider receipt、支付凭证全文和敏感证件信息。 ## Development Plan 1. 保持当前干净账本表:`wallet_accounts`、`wallet_transactions`、`wallet_entries`、`wallet_outbox`、`wallet_gift_prices`。 2. `DebitGift` 只走 v2 账本和服务端礼物价格,不保留客户端单价字段。 3. 增加后台礼物价格配置、资源库选礼物、上下架和审计入口。 4. 增加充值、兑换、提现订单表和状态机。 5. 增加钱包 outbox worker 和 MQ 投递适配。 ## Acceptance Criteria 账务基础: - 同一 `command_id`、同一 payload 重复调用 `DebitGift`,余额只变化一次,返回同一 `billing_receipt_id`。 - 同一 `command_id`、不同 payload 调用 `DebitGift`,返回 `REQUEST_CONFLICT`,余额不变。 - 并发多个扣费请求打同一个 sender 账户,不出现负数余额。 - 余额不足时没有成功交易、没有分录、没有 outbox。 送礼: - 钱包按 `wallet_gift_prices` 计算扣费、积分和热度,客户端没有礼物单价输入。 - 扣费成功后 sender `COIN` 减少,target `GIFT_POINT` 增加,room-service 使用钱包返回 `heat_value` 更新房间热度。 - 腾讯 IM 或 room outbox 投递失败不影响钱包交易事实。 充值和兑换: - 同一个 Apple/Google provider transaction 重复确认,只返回原到账结果。 - provider 校验失败不会产生资产分录。 - 币商转金币在一个事务中扣币商、加用户,任一侧失败则整体失败。 - 钻石兑换成功后扣 `DIAMOND` 和加目标资产是原子结果。 提现: - 提现申请成功后 `USD_BALANCE.available_amount` 减少,`frozen_amount` 增加。 - 审核拒绝后冻结金额回到 available。 - 打款完成后 frozen 减少并写出账分录。 - 打款失败后状态可重试,不丢失冻结关系和审核上下文。 验证命令: ```bash make proto go test ./services/wallet-service/... go test ./services/room-service/... go test ./services/gateway-service/... docker compose config ``` 涉及 Docker、配置或 provider mock 时再补 `docker compose build` 和实际 `docker compose up -d` 冒烟。 ## Implementation Order 1. 新增 v2 账本表:`wallet_accounts` 扩展为多资产 available/frozen,新增 `wallet_transactions`、`wallet_entries`、`wallet_outbox`。 2. 重构 repository:提供通用 `ApplyTransaction`,在一个 MySQL 事务中完成幂等、锁账户、写分录、更新余额。 3. 保持现有 `DebitGift` RPC 可用,内部改为 `COIN` 扣费和 `GIFT_POINT` 入账。 4. 增加余额查询和管理端手动充值,先打通运营可测链路。 5. 增加 Apple/Google 订单和 receipt 校验,provider transaction id 做唯一约束。 6. 增加币商账户、币商转账、区域充值政策快照和限额审计。 7. 增加钻石兑换订单。 8. 增加主播奖励结算和提现冻结/审核/人工打款闭环。 ## Critical Rules - 任何入账、扣款、冻结、解冻、出账都必须写 transaction 和 entry,不能直接 update balance。 - 幂等键必须覆盖业务语义,同一个 command_id 带不同 payload 必须返回冲突。 - Apple/Google 充值以 provider 真实交易号为最终幂等源,不能只信客户端 request id。 - 币商充值分两段:平台给币商发 `COIN_SELLER_COIN` 库存,币商再把库存转成用户 `COIN`;必须有币商金币余额、充值政策快照、限额和审计。 - `USD_BALANCE` 是可提现负债,调账和奖励发放需要更高权限和审计。 - 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。 - 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。 - 钱包事件投递使用 outbox,MQ 投递失败不回滚账务事实。 - 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。