Files

354 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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` 流水。接受:开发阶段,测试数据可清空重来,不引入回填复杂度。