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