# 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 投递失败不回滚账务事实。
- 礼物价格和积分比例必须来自服务端配置，不能由客户端决定。
