hyapp-server/docs/钱包服务架构.md

541 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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。
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
- 钱包事件投递使用 outboxMQ 投递失败不回滚账务事实。
- 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。