## 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` 流水。接受:开发阶段,测试数据可清空重来,不引入回填复杂度。