422 lines
14 KiB
Markdown
422 lines
14 KiB
Markdown
# 币商进货与金币补偿架构设计
|
||
|
||
## 目标
|
||
|
||
币商管理列表增加“加金币”按钮。运营点击后打开“币商进货”弹窗,给币商的专用金币账户 `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 金额和操作人。
|