# 币商进货与金币补偿架构设计

## 目标

币商管理列表增加“加金币”按钮。运营点击后打开“币商进货”弹窗，给币商的专用金币账户 `COIN_SELLER_COIN` 增加库存。

表单字段：

- `充值金币`：增加到币商库存的金币数量，必填。
- `类型`：`USDT进货` / `金币补偿`，必填。
- `充值金额`：币商线下支付给平台的 USDT 金额，仅 `USDT进货` 必填。

业务口径：

- `USDT进货`：平台收到币商线下 USDT 后，后台给币商发放 `COIN_SELLER_COIN` 库存；这笔记录计入币商进货/充值统计。
- `金币补偿`：平台因为异常、活动、人工修正等原因给币商补库存；没有充值金额，不计入币商进货/充值统计。
- 两种类型都不能给币商发放 `USD_BALANCE`，也不能直接影响玩家普通 `COIN`。

## 服务边界

| 模块 | 职责 |
| --- | --- |
| `server/admin` | 币商列表、按钮权限、弹窗表单、后台审计、调用 user-service 校验币商身份、调用 wallet-service 发放库存 |
| `user-service` | 保存 `coin_seller_profiles` 身份事实，判断目标用户是否是有效币商 |
| `wallet-service` | 拥有 `COIN_SELLER_COIN` 账户、钱包流水、币商进货记录、幂等和 outbox |
| `gateway-service` | 不参与后台币商进货；App 币商给玩家转金币仍走现有 `/api/v1/wallet/coin-seller/transfer` |

边界规则：

- `server/admin` 不能直接改 `wallet_accounts`。
- `user-service` 不能保存币商余额。
- `wallet-service` 不判断后台菜单权限，但必须保存完整账务流水和进货记录。
- 币商库存入账必须走钱包命令，不能用通用 SQL 修余额。

## 后台交互

### 币商列表

现有币商列表继续展示：

- 用户 ID / 短号
- 昵称 / 头像
- 区域
- 币商状态
- 币商库存余额 `merchantBalance`
- 创建时间 / 更新时间

新增行操作：

| 按钮 | 权限 | 状态 |
| --- | --- | --- |
| `加金币` | `coin-seller:stock-credit` | 仅 active 币商可点击；disabled 币商按钮置灰 |

如果后续产品要求 disabled 币商也能补偿库存，必须新增单独权限或二次确认，不在首版默认放开。

### 弹窗表单

弹窗标题：`币商进货`

只读信息：

- 币商用户 ID
- 币商短号
- 昵称
- 当前库存余额
- 区域

可编辑字段：

| 字段 | 类型 | 规则 |
| --- | --- | --- |
| `type` | 单选 | `usdt_purchase` / `coin_compensation` |
| `coin_amount` | 正整数 | 两种类型都必填，表示增加的 `COIN_SELLER_COIN` |
| `recharge_amount` | 金额 | 仅 `usdt_purchase` 展示并必填；`coin_compensation` 不展示也不提交 |
| `payment_ref` | 文本 | `usdt_purchase` 必填，建议填链上 tx hash、收款单号或财务凭证号 |
| `evidence_ref` | 文本 | 凭证附件或后台上传后的文件 ID；首版建议两种类型都必填 |
| `reason` | 文本 | 必填，写清楚进货或补偿原因 |
| `command_id` | 文本 | 操作端前端按单次授信动作生成，同一动作重试必须复用 |

金额存储规则：

- `recharge_amount` 前端可以展示小数，例如 `120.50`。
- 服务端统一转成 `paid_amount_micro`，USDT 按 6 位精度保存。
- `coin_compensation` 必须满足 `paid_currency_code=''` 且 `paid_amount_micro=0`，如果客户端传了金额，服务端直接拒绝。

## 账务模型

币商库存仍然是钱包资产：

- 资产类型：`COIN_SELLER_COIN`
- 账户表：`wallet_accounts`
- 流水表：`wallet_transactions`
- 分录表：`wallet_entries`
- 事件表：`wallet_outbox`

新增专门的币商进货明细表，避免从通用钱包 metadata 里做统计。

```sql
coin_seller_stock_records(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  stock_id VARCHAR(96) NOT NULL,
  transaction_id VARCHAR(96) NOT NULL,
  command_id VARCHAR(128) NOT NULL,
  seller_user_id BIGINT NOT NULL,
  stock_type VARCHAR(32) NOT NULL,
  counts_as_seller_recharge BOOLEAN NOT NULL,
  coin_amount BIGINT NOT NULL,
  paid_currency_code VARCHAR(8) NOT NULL DEFAULT '',
  paid_amount_micro BIGINT NOT NULL DEFAULT 0,
  payment_ref VARCHAR(128) NULL,
  evidence_ref VARCHAR(256) NOT NULL DEFAULT '',
  operator_user_id BIGINT NOT NULL,
  reason VARCHAR(512) NOT NULL DEFAULT '',
  balance_after BIGINT NOT NULL,
  metadata_json JSON NULL,
  created_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, stock_id),
  UNIQUE KEY uk_coin_seller_stock_command (app_code, command_id),
  UNIQUE KEY uk_coin_seller_stock_payment_ref (app_code, stock_type, payment_ref),
  KEY idx_coin_seller_stock_seller_time (app_code, seller_user_id, created_at_ms),
  KEY idx_coin_seller_stock_type_time (app_code, stock_type, created_at_ms)
);
```

字段语义：

- `stock_type='usdt_purchase'`：`counts_as_seller_recharge=true`，`paid_currency_code='USDT'`，`paid_amount_micro>0`。
- `stock_type='coin_compensation'`：`counts_as_seller_recharge=false`，`paid_currency_code=''`，`paid_amount_micro=0`。
- `coin_amount` 是发给币商的库存金币，不是玩家充值金币。
- `payment_ref` 对 USDT 进货必须唯一，防止同一笔线下收款重复入账。
- `balance_after` 保存账后币商库存，后台详情不用再回放钱包分录。

注意：这张表不是 `wallet_recharge_records`。`wallet_recharge_records` 只记录币商给玩家转金币后形成的“玩家充值事实”。币商自己进货应该单独统计，不能混到玩家充值记录里。

## 业务类型

钱包交易 `biz_type` 建议拆成两个值：

| `biz_type` | 说明 | 是否计入币商充值统计 |
| --- | --- | --- |
| `coin_seller_stock_purchase` | USDT 进货，平台收到币商付款后发放库存 | 是 |
| `coin_seller_coin_compensation` | 人工金币补偿、异常修复、活动补库存 | 否 |

不要复用 `manual_credit`，因为通用后台入账无法表达：

- 是否是币商库存
- 是否是 USDT 进货
- 是否计入币商充值统计
- 线下付款凭证是否唯一
- 金币补偿没有充值金额这个约束

## 主链路

```mermaid
sequenceDiagram
    autonumber
    participant Admin as Admin Web
    participant AdminSrv as server/admin
    participant User as user-service
    participant Wallet as wallet-service
    participant DB as wallet MySQL
    participant Audit as admin_operation_logs

    Admin->>AdminSrv: POST /admin/coin-sellers/{user_id}/stock-credits
    AdminSrv->>AdminSrv: 校验 coin-seller:stock-credit 权限和表单
    AdminSrv->>User: GetCoinSellerProfile(user_id)
    User-->>AdminSrv: active coin_seller profile
    AdminSrv->>Wallet: AdminCreditCoinSellerStock(command)
    Wallet->>DB: BEGIN
    Wallet->>DB: 按 command_id / payment_ref 做幂等和重复收款校验
    Wallet->>DB: SELECT wallet_accounts FOR UPDATE
    Wallet->>DB: 增加 COIN_SELLER_COIN
    Wallet->>DB: 写 wallet_transactions / wallet_entries
    Wallet->>DB: 写 coin_seller_stock_records
    Wallet->>DB: 写 wallet_outbox
    Wallet->>DB: COMMIT
    AdminSrv->>Audit: 写后台操作审计
    AdminSrv-->>Admin: 返回 transaction_id 和账后余额
```

失败边界：

- user-service 判断不是 active 币商时，不调用 wallet-service。
- wallet-service 入账失败时，不写成功审计；可以写失败审计或错误日志。
- 钱包事务提交成功但后台审计写失败时，必须记录告警；后续需要审计补偿任务，不能静默吞掉。

## Admin API

新增后台接口：

```http
POST /admin/coin-sellers/{user_id}/stock-credits
```

权限：

```text
coin-seller:stock-credit
```

请求：

```json
{
  "commandId": "admin-coin-seller-stock-20260509-001",
  "type": "usdt_purchase",
  "coinAmount": 8000000,
  "rechargeAmount": "100.000000",
  "paymentRef": "usdt-tx-hash-or-offline-receipt",
  "evidenceRef": "oss://admin-evidence/receipt.png",
  "reason": "币商 USDT 进货"
}
```

金币补偿请求：

```json
{
  "commandId": "admin-coin-seller-comp-20260509-001",
  "type": "coin_compensation",
  "coinAmount": 50000,
  "evidenceRef": "ticket-12345",
  "reason": "上次转账异常补偿"
}
```

响应：

```json
{
  "transactionId": "lalu_admin_coin_seller_stock_...",
  "sellerUserId": "163001",
  "stockType": "usdt_purchase",
  "coinAmount": 8000000,
  "paidCurrencyCode": "USDT",
  "paidAmountMicro": 100000000,
  "countsAsSellerRecharge": true,
  "balanceAfter": 12800000,
  "createdAtMs": 1760000000000
}
```

校验：

- `user_id` 必须是 active `coin_seller`。
- `coinAmount > 0`。
- `type=usdt_purchase` 时，`rechargeAmount > 0`、`paymentRef` 必填、`paid_currency_code=USDT`。
- `type=coin_compensation` 时，不能提交 `rechargeAmount`，`paid_amount_micro` 固定为 0。
- `reason` 必填。
- `evidenceRef` 首版建议必填，方便财务和客服追踪。

## gRPC 契约建议

在 `wallet.v1.WalletService` 增加后台命令：

```protobuf
message AdminCreditCoinSellerStockRequest {
  string command_id = 1;
  int64 seller_user_id = 2;
  string stock_type = 3;
  int64 coin_amount = 4;
  string paid_currency_code = 5;
  int64 paid_amount_micro = 6;
  string payment_ref = 7;
  string evidence_ref = 8;
  int64 operator_user_id = 9;
  string reason = 10;
  string app_code = 11;
}

message AdminCreditCoinSellerStockResponse {
  string transaction_id = 1;
  int64 seller_user_id = 2;
  string stock_type = 3;
  int64 coin_amount = 4;
  string paid_currency_code = 5;
  int64 paid_amount_micro = 6;
  bool counts_as_seller_recharge = 7;
  int64 balance_after = 8;
  int64 created_at_ms = 9;
}
```

`server/admin` 负责在调用前通过 `user-service` 校验币商身份；`wallet-service` 负责校验金额、类型、幂等、重复 `payment_ref` 和钱包事务。

## 统计口径

币商库存相关统计从 `coin_seller_stock_records` 读取：

| 指标 | 查询条件 |
| --- | --- |
| 币商累计 USDT 进货金币 | `stock_type='usdt_purchase'` |
| 币商累计 USDT 进货金额 | `counts_as_seller_recharge=true`，汇总 `paid_amount_micro` |
| 币商累计金币补偿 | `stock_type='coin_compensation'` |
| 当前库存余额 | `wallet_accounts.asset_type='COIN_SELLER_COIN'` |
| 币商给玩家充值金币 | 现有 `wallet_recharge_records` 或 `biz_type='coin_seller_transfer'` |

金币补偿不计入币商充值统计的落地方式：

- 表单不展示 `充值金额`。
- API 不接受 `rechargeAmount`。
- 数据库存 `paid_amount_micro=0`。
- `counts_as_seller_recharge=false`。
- 统计 SQL 必须带 `counts_as_seller_recharge=true` 或 `stock_type='usdt_purchase'`。

## 列表性能

币商列表不要每行实时做大范围流水汇总。

首版建议：

- 列表只展示当前库存余额 `merchantBalance`，继续按当前实现从 `wallet_accounts` 按当页 user_id 批量查询。
- 如果产品要展示累计进货金额、累计补偿金币、最近进货时间，只对当前页 user_id 从 `coin_seller_stock_records` 做 group by。
- 如果后续需要按累计进货金额排序或筛选，再增加 `coin_seller_stock_stats` 投影表。

可选投影表：

```sql
coin_seller_stock_stats(
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  seller_user_id BIGINT NOT NULL,
  purchase_coin_total BIGINT NOT NULL DEFAULT 0,
  purchase_paid_usdt_micro_total BIGINT NOT NULL DEFAULT 0,
  compensation_coin_total BIGINT NOT NULL DEFAULT 0,
  last_stock_at_ms BIGINT NOT NULL DEFAULT 0,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, seller_user_id)
);
```

投影更新可以和钱包事务同事务完成，也可以由 wallet outbox 异步更新。首版如果列表不展示累计值，不需要这张表。

## Outbox 事件

钱包事务成功后写出：

- `WalletCoinSellerStockPurchased`
- `WalletCoinSellerCoinCompensated`
- `WalletBalanceChanged`

事件 payload：

- `seller_user_id`
- `stock_type`
- `coin_amount`
- `paid_currency_code`
- `paid_amount_micro`
- `counts_as_seller_recharge`
- `transaction_id`
- `operator_user_id`
- `created_at_ms`

这些事件可用于后台通知、财务报表、风控告警和统计投影。

## 权限和审计

新增权限：

| Permission | Kind | Usage |
| --- | --- | --- |
| `coin-seller:stock-credit` | `button` | 币商进货、金币补偿 |

审计 action：

| Action | Resource | 说明 |
| --- | --- | --- |
| `coin-seller-stock-credit` | `coin_seller_stock_records` | USDT 进货或金币补偿成功 |

审计 detail 必须包含：

- `command_id`
- `seller_user_id`
- `stock_type`
- `coin_amount`
- `paid_currency_code`
- `paid_amount_micro`
- `payment_ref`
- `transaction_id`
- `reason`

USDT 进货金额属于财务口径，不能只放前端日志。

## 错误码建议

纳入 `pkg/xerr` catalog：

| 业务错误 | gRPC code | 说明 |
| --- | --- | --- |
| `COIN_SELLER_NOT_ACTIVE` | `FailedPrecondition` | 目标用户不是 active 币商 |
| `COIN_SELLER_STOCK_TYPE_INVALID` | `InvalidArgument` | 类型不是 USDT 进货或金币补偿 |
| `COIN_SELLER_STOCK_AMOUNT_INVALID` | `InvalidArgument` | 金币数或 USDT 金额不合法 |
| `COIN_SELLER_PAYMENT_REF_DUPLICATED` | `AlreadyExists` | 同一 USDT 凭证已入账 |
| `IDEMPOTENCY_CONFLICT` | `AlreadyExists` | command_id 重复但 payload 不同 |

客户端展示文案由 admin 前端根据业务 code 转换，不从内部错误字符串拼 UI。

## 实现顺序

1. 增加权限种子：`coin-seller:stock-credit`，币商列表行操作按权限展示。
2. 扩展 `api/proto/wallet/v1/wallet.proto`：新增 `AdminCreditCoinSellerStock` 请求和响应。
3. 增加 wallet MySQL 表 `coin_seller_stock_records`。
4. 在 `wallet-service` 实现专用命令：校验类型、金额、payment_ref、幂等，锁定 `COIN_SELLER_COIN` 账户并入账。
5. 在 `server/admin` 增加接口：校验 active 币商身份，调用 wallet-service，成功后写 `admin_operation_logs`。
6. 后台页面币商列表增加“加金币”按钮和弹窗表单。
7. 增加列表成功后的刷新策略：成功后刷新当前行余额或重新拉取当前页。
8. 如需累计统计，再增加当前页聚合查询或 `coin_seller_stock_stats` 投影。

## 测试重点

必须覆盖：

- active 币商 USDT 进货成功，`COIN_SELLER_COIN` 增加，`counts_as_seller_recharge=true`。
- USDT 进货缺少 `rechargeAmount` 或 `paymentRef` 被拒绝。
- 同一个 `paymentRef` 不能重复进货。
- 金币补偿成功，`COIN_SELLER_COIN` 增加，`paid_amount_micro=0`，`counts_as_seller_recharge=false`。
- 金币补偿传入 `rechargeAmount` 被拒绝。
- disabled 或不存在的币商不能加金币。
- 相同 `command_id` 和相同 payload 重试返回原交易结果。
- 相同 `command_id` 但 payload 不同返回幂等冲突。
- wallet 事务失败时不写进货记录、不改余额。
- admin 审计记录包含 transaction_id、类型、金币数、USDT 金额和操作人。
