Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
354 lines
17 KiB
Markdown
354 lines
17 KiB
Markdown
## 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` 流水。接受:开发阶段,测试数据可清空重来,不引入回填复杂度。
|