docs: 归档 asset-wallet-interface OpenSpec 提案,更新卡钱包 spec

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-03-16 23:30:48 +08:00
parent 63ca12393b
commit f3297f0529
8 changed files with 927 additions and 148 deletions

View File

@@ -0,0 +1,353 @@
## Context
`asset-detail-refactor` 建立了 `/api/admin/assets/` 路径下的完整资产体系(解析、状态、套餐、停复机),但缺少钱包维度。现有钱包体系命名混乱:`CardWallet` / `tb_card_wallet` 实际同时承载 `iot_card``device` 两种资产名不副实。此外H5 端个人客户用钱包支付套餐时(`WalletPay`)代码直接 `UPDATE` 余额,从未写入 `CardWalletTransaction` 流水记录,导致流水表只有充值记录、没有扣款记录。
本次设计目标:改名 + 补流水 + 新增 Admin 查询接口,三件事合并在一个变更里。
## Goals / Non-Goals
**Goals:**
- 数据表改名:`tb_card_wallet*``tb_asset_wallet*`,代码全量跟进
- 流水表字段变更:`reference_id (bigint)``reference_no (varchar 50)`,存储可读业务编号
- 补写 `WalletPay` 卡钱包路径的 `deduct` 流水,填补现有数据空白
- 新增 Admin 端资产钱包概况接口 `GET /api/admin/assets/:asset_type/:id/wallet`
- 新增 Admin 端资产钱包流水列表接口 `GET /api/admin/assets/:asset_type/:id/wallet/transactions`
- 企业账号禁止访问钱包接口;代理账号通过 `shop_id_tag` 自动过滤
**Non-Goals:**
- H5 端不新增/修改任何接口(现有充值 / 订单接口 JSON 字段名不变)
- 不新增 Admin 端充值单列表接口(充值记录通过流水的 `reference_no` 跳转即可)
- 不做历史数据回填(`WalletPay` 修复只对新数据有效)
- 代理主钱包(`AgentWallet`)不在本次范围
## Decisions
### 决策 1三张表全部改名保持 `tb_asset_*` 前缀统一
```
tb_card_wallet → tb_asset_wallet
tb_card_wallet_transaction → tb_asset_wallet_transaction
tb_card_recharge_record → tb_asset_recharge_record
```
代码层 Go 类型名同步改名:
```
CardWallet → AssetWallet
CardWalletTransaction → AssetWalletTransaction
CardRechargeRecord → AssetRechargeRecord
CardWalletStore → AssetWalletStore
CardWalletTransactionStore → AssetWalletTransactionStore
CardRechargeStore → AssetRechargeStore
```
Redis Key 常量更名:`RedisCardWalletBalanceKey``RedisAssetWalletBalanceKey`
### 决策 2`reference_id (bigint)` → `reference_no (varchar 50)`
原来存主键 ID前端无法直接用于展示或跳转改为存业务编号
- 充值场景:`reference_no = recharge.RechargeNo`(格式:`CRCH…`
- 扣款场景:`reference_no = order.OrderNo`(格式:`ORD…`
现有流水数据全部在开发阶段写入(无生产数据),直接 `ALTER COLUMN`,不需要数据迁移。
### 决策 3`WalletPay` 卡钱包路径补写 `deduct` 流水
在卡钱包扣款成功后,在同一事务内写入 `AssetWalletTransaction`
```go
transaction := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: resourceType, // "iot_card" 或 "device"
ResourceID: resourceID,
UserID: buyerID,
TransactionType: "deduct",
Amount: -order.TotalAmount, // 负数
BalanceBefore: walletBalanceBefore,
BalanceAfter: walletBalanceBefore - order.TotalAmount,
Status: 1,
ReferenceType: strPtr("order"),
ReferenceNo: &order.OrderNo,
Remark: strPtr("钱包支付套餐"),
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
```
### 决策 4权限控制复用 `ApplyShopTagFilter`
`tb_asset_wallet``tb_asset_wallet_transaction` 都有 `shop_id_tag` 字段,已有 `ApplyShopTagFilter` 机制:
- 平台/超管:不添加过滤条件
- 代理用户:`WHERE shop_id_tag IN (当前店铺及下级店铺IDs)`
- 企业账号:在 Handler 层检查 `user_type == UserTypeEnterprise`,直接返回 403
### 决策 5新接口挂载在现有 `/assets/` 路径下,由 `AssetWalletHandler` 承载
独立的 Handler 文件 `internal/handler/admin/asset_wallet.go`,通过 `AssetWalletService` 提供服务逻辑。路由注册追加到 `internal/routes/asset.go`
---
## API 合约
### 接口一:查询资产钱包概况
```
GET /api/admin/assets/:asset_type/:id/wallet
```
**路径参数**
| 参数 | 类型 | 说明 |
|------|------|------|
| `asset_type` | string | 资产类型:`card``device` |
| `id` | uint | 资产数据库 ID |
**请求体**:无
**成功响应** `200 OK`
```json
{
"code": 0,
"msg": "success",
"data": {
"wallet_id": 123,
"resource_type": "iot_card",
"resource_id": 456,
"balance": 10000,
"frozen_balance": 0,
"available_balance": 10000,
"currency": "CNY",
"status": 1,
"status_text": "正常",
"created_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-03-10T00:00:00Z"
},
"timestamp": 1741564800
}
```
**响应字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `wallet_id` | uint | 钱包数据库 ID |
| `resource_type` | string | `iot_card``device` |
| `resource_id` | uint | 对应卡或设备的数据库 ID |
| `balance` | int64 | 总余额(分) |
| `frozen_balance` | int64 | 冻结余额(分) |
| `available_balance` | int64 | 可用余额 = balance - frozen_balance |
| `currency` | string | 币种,目前固定 `CNY` |
| `status` | int | 钱包状态1-正常 2-冻结 3-关闭 |
| `status_text` | string | 状态文本 |
| `created_at` | string | 创建时间RFC3339 |
| `updated_at` | string | 更新时间RFC3339 |
**错误响应**
| 场景 | HTTP 状态码 | 错误码 | 错误消息 |
|------|------------|--------|---------|
| `asset_type` 非法 | 400 | `CodeInvalidParam` | 无效的资产类型 |
| `id` 非法 | 400 | `CodeInvalidParam` | 无效的资产ID |
| 资产不存在 | 404 | `CodeNotFound` | 资产不存在 |
| 钱包不存在 | 404 | `CodeNotFound` | 该资产暂无钱包记录 |
| 企业账号调用 | 403 | `CodeForbidden` | 企业账号无权查看钱包信息 |
---
### 接口二:查询资产钱包流水列表
```
GET /api/admin/assets/:asset_type/:id/wallet/transactions
```
**路径参数**:同上
**查询参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page` | int | 否 | 页码,默认 1 |
| `page_size` | int | 否 | 每页数量,默认 20最大 100 |
| `transaction_type` | string | 否 | 类型过滤:`recharge` / `deduct` / `refund` |
| `start_time` | string | 否 | 开始时间RFC3339 |
| `end_time` | string | 否 | 结束时间RFC3339 |
**成功响应** `200 OK`
```json
{
"code": 0,
"msg": "success",
"data": {
"list": [
{
"id": 1,
"transaction_type": "deduct",
"transaction_type_text": "扣款",
"amount": -3000,
"balance_before": 10000,
"balance_after": 7000,
"reference_type": "order",
"reference_no": "ORD20260310001",
"remark": "钱包支付套餐",
"created_at": "2026-03-10T14:20:00Z"
},
{
"id": 2,
"transaction_type": "recharge",
"transaction_type_text": "充值",
"amount": 10000,
"balance_before": 0,
"balance_after": 10000,
"reference_type": "recharge",
"reference_no": "CRCH20260309001",
"remark": "钱包充值",
"created_at": "2026-03-09T09:15:00Z"
}
],
"total": 2,
"page": 1,
"page_size": 20,
"total_pages": 1
},
"timestamp": 1741564800
}
```
**响应字段说明(单条流水)**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | uint | 流水记录 ID |
| `transaction_type` | string | 交易类型:`recharge`/`deduct`/`refund` |
| `transaction_type_text` | string | 交易类型文本:充值/扣款/退款 |
| `amount` | int64 | 变动金额(分),充值为正数,扣款/退款为负数 |
| `balance_before` | int64 | 变动前余额(分) |
| `balance_after` | int64 | 变动后余额(分) |
| `reference_type` | string | 关联业务类型:`recharge``order`(可空) |
| `reference_no` | string | 关联业务编号充值单号CRCH…或订单号ORD…可空 |
| `remark` | string | 备注(可空) |
| `created_at` | string | 流水创建时间RFC3339 |
**错误响应**:同接口一
---
## 接口调用流程图
### 场景一Admin 查看资产钱包详情(典型调用链)
```
前端Admin 页面) API 服务 数据库
│ │ │
│── ① 解析资产 ─────────────→│ │
│ GET /assets/resolve/:identifier │
│ │── AssetService.Resolve() ──→ │
│ │ SELECT iot_card/device │
│← 返回 {asset_type, asset_id}│←────────────────────────────│
│ │ │
│── ② 查询钱包概况 ──────────→│ │
│ GET /assets/card/456/wallet │
│ │── AssetWalletService │
│ │ .GetWallet() │
│ │── SELECT tb_asset_wallet ──→ │
│ │ WHERE resource_type='iot_card'
│ │ AND resource_id=456 │
│← 返回 {balance, available…}│←────────────────────────────│
│ │ │
│── ③ 查询流水列表 ──────────→│ │
│ GET /assets/card/456/wallet/transactions?page=1 │
│ │── AssetWalletService │
│ │ .ListTransactions() │
│ │── SELECT tb_asset_wallet_transaction
│ │ WHERE resource_type='iot_card'
│ │ AND resource_id=456 │
│ │ ORDER BY created_at DESC │
│← 返回流水列表 ─────────────│←────────────────────────────│
│ [{type:deduct, ref:ORD…}, │ │
│ {type:recharge, ref:CRCH…}] │
│ │ │
│── ④(可选)前端用 reference_no 跳转到充值单或订单详情页 │
```
### 场景二H5 个人客户钱包支付(补写流水后的完整事务)
```
H5 前端 order.Service.WalletPay() 数据库(事务)
│ │ │
│── POST /orders/:id/wallet-pay→│ │
│ │ │
│ │── ① 查询订单 ─────────────────→ │
│ │ SELECT tb_order WHERE id=:id │
│ │←──────────────────────────────── │
│ │ │
│ │── ② 查询 AssetWallet ─────────→ │
│ │ SELECT tb_asset_wallet │
│ │ WHERE resource_type+resource_id│
│ │←──────────────────────────────── │
│ │ │
│ │── ③ 开启事务 ─────────────────→ │
│ │ BEGIN │
│ │ │
│ │── ④ 更新订单支付状态 ─────────→ │
│ │ UPDATE tb_order │
│ │ SET payment_status=paid │
│ │ WHERE id=:id AND status=pending│
│ │←──────────────────────────────── │
│ │ │
│ │── ⑤ 扣减钱包余额(乐观锁)────→ │
│ │ UPDATE tb_asset_wallet │
│ │ SET balance=balance-amount │
│ │ WHERE id=:id AND version=:v │
│ │←──────────────────────────────── │
│ │ │
│ │── ⑥ ★ 写入扣款流水(新增)────→ │
│ │ INSERT tb_asset_wallet_transaction
│ │ (transaction_type="deduct", │
│ │ amount=-totalAmount, │
│ │ reference_type="order", │
│ │ reference_no=order.OrderNo) │
│ │←──────────────────────────────── │
│ │ │
│ │── ⑦ 激活套餐 ─────────────────→ │
│ │ activatePackage() │
│ │←──────────────────────────────── │
│ │ │
│ │── ⑧ 提交事务 ─────────────────→ │
│ │ COMMIT │
│← 200 OK ──────────────────── │ │
```
---
## 字段变更对现有接口的影响分析
### H5 充值接口(**无 breaking change**
以下 H5 接口 JSON 响应字段名保持不变,前端零感知:
| 接口 | JSON 字段 | Go 字段改名 | 影响 |
|------|-----------|------------|------|
| `POST /api/h5/wallets/recharge` 响应 | `wallet_id` | `CardRechargeRecord.CardWalletID``AssetRechargeRecord.AssetWalletID` | JSON tag 不变,**无影响** |
| `GET /api/h5/wallets/recharges` 响应 | `wallet_id` | 同上 | JSON tag 不变,**无影响** |
| `GET /api/h5/wallets/recharges/:id` 响应 | `wallet_id` | 同上 | JSON tag 不变,**无影响** |
### 新增接口中 `reference_no` 字段(**全新字段,无旧调用方**
`tb_asset_wallet_transaction.reference_no` 是首次通过 API 暴露。变更前该字段叫 `reference_id`uint从未被任何接口返回不存在已有调用方**无 breaking change**。
---
## Risks / Trade-offs
**[风险 1] `WalletPay` 中余额快照时机**
扣款前需记录 `balance_before`,但乐观锁可能因并发重试导致实际余额与快照不符。缓解:在事务内先查询钱包余额作为快照,再执行乐观锁更新;`balance_before` = 查询值,`balance_after` = 查询值 - amount若乐观锁失败则整个事务回滚流水不写入。
**[风险 2] 表改名期间的零停机**
开发阶段无生产数据,直接 migration 改名,无需双写策略。
**[Trade-off] 不回填历史扣款流水**
`WalletPay` 修复前的历史订单无对应 `deduct` 流水。接受:开发阶段,测试数据可清空重来,不引入回填复杂度。