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