541 lines
29 KiB
Markdown
541 lines
29 KiB
Markdown
# Wallet Service Architecture
|
||
|
||
本文定义钱包服务的目标架构、账务模型、核心流程和落地顺序。当前 `wallet-service` 只有单币种 `coin` 扣费能力,新需求需要升级为多资产账户、外部支付订单、币商充值、主播奖励和提现审核的一体化账务边界。
|
||
|
||
资源、资源组、用户资源赠送和礼物从资源库选择的边界见 [Resource And Resource Group Architecture](./resource-group-gift-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 投递失败不回滚账务事实。
|
||
- 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。
|