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,2 @@
schema: spec-driven
created: 2026-03-16

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

View File

@@ -0,0 +1,60 @@
## Why
`asset-detail-refactor` 完成了资产详情体系(解析、状态、套餐、停复机),但遗漏了资产钱包维度的查询入口:管理员无法在 Admin 端查看某张卡或设备的钱包余额与收支流水。同时,现有 `CardWallet` 系列命名(`tb_card_wallet``CardWalletStore`与实际承载的两种资产iot_card + device不符趁本次新增接口前一并清理统一更名为 `AssetWallet`
## What Changes
- **BREAKING内部重命名** `CardWallet``AssetWallet`:三张数据库表改名,对应 Model / Store / bootstrap / Redis Key 全量重命名H5 recharge 接口的 JSON 字段名不变,前端零感知
- **BREAKING内部字段变更** `tb_asset_wallet_transaction.reference_id (bigint)``reference_no (varchar 50)`存储充值单号CRCH…或订单号ORD…便于前端直接跳转
- **新增** 在 `WalletPay`(卡钱包支付路径)中补写 `AssetWalletTransaction` 扣款流水(`transaction_type="deduct"`, `reference_no=order.OrderNo`),修复现有流水表中扣款记录缺失的问题
- **新增** 管理端资产钱包概况接口 `GET /api/admin/assets/:asset_type/:id/wallet`
- **新增** 管理端资产钱包流水列表接口 `GET /api/admin/assets/:asset_type/:id/wallet/transactions`
## Capabilities
### New Capabilities
- `asset-wallet-query`Admin 端查询资产(卡/设备)关联钱包的余额概况与收支流水,支持分页,含充值/扣款流水的来源编号可跳转
### Modified Capabilities
- `asset-wallet``tb_card_wallet``tb_card_wallet_transaction``tb_card_recharge_record` 三张表统一改名为 `tb_asset_wallet``tb_asset_wallet_transaction``tb_asset_recharge_record``reference_id (bigint)` 字段改为 `reference_no (varchar 50)``WalletPay` 补写扣款 transaction 记录
## Impact
**数据库迁移**
- `tb_card_wallet``tb_asset_wallet`(含 `reference_id``reference_no` 字段变更)
- `tb_card_wallet_transaction``tb_asset_wallet_transaction`
- `tb_card_recharge_record``tb_asset_recharge_record`
**Model 层**
- `internal/model/card_wallet.go` 全量重命名:`CardWallet``AssetWallet``CardWalletTransaction``AssetWalletTransaction``CardRechargeRecord``AssetRechargeRecord`
- `AssetWalletTransaction.ReferenceID *uint``ReferenceNo *string`
**Store 层**
- `card_wallet_store.go``asset_wallet_store.go``CardWalletStore``AssetWalletStore`
- `card_wallet_transaction_store.go``asset_wallet_transaction_store.go`
- `card_recharge_store.go``asset_recharge_store.go`
**Service 层**
- `internal/service/order/service.go``cardWalletStore``assetWalletStore``WalletPay` 卡钱包路径补写扣款 transaction
- `internal/service/recharge/service.go``cardWalletStore``assetWalletStore`;交易记录写入 `ReferenceNo = recharge.RechargeNo`
**Handler 层**
- 新增 `internal/handler/admin/asset_wallet.go``AssetWalletHandler`(两个新 Handler 方法)
**路由层**
- `internal/routes/asset.go` 新增两条路由(`wallet``wallet/transactions`
**Bootstrap 层**
- `internal/bootstrap/stores.go``CardWallet*``AssetWallet*`
- `internal/bootstrap/services.go`:更新依赖注入
**常量层**
- `pkg/constants/redis.go``RedisCardWalletBalanceKey``RedisAssetWalletBalanceKey`
**DTO 层**
- `internal/model/dto/` 新增 `asset_wallet_dto.go``AssetWalletResponse``AssetWalletTransactionListRequest``AssetWalletTransactionListResponse``AssetWalletTransactionItem`
**API 文档**
- `cmd/api/docs.go``cmd/gendocs/main.go` 新增 `AssetWalletHandler`

View File

@@ -0,0 +1,79 @@
## ADDED Requirements
### Requirement: Admin 端查询资产钱包概况
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet` 接口,允许平台用户和代理账号查询指定卡或设备的钱包余额概况。
**接口规格**
- 路径参数 `asset_type``card``device`
- 路径参数 `id`:资产数据库 IDuint
- 无请求体
- 返回字段:`wallet_id``resource_type``resource_id``balance``frozen_balance``available_balance``currency``status``status_text``created_at``updated_at`
**权限规则**
- 平台用户/超级管理员:可查询所有资产钱包
- 代理账号:只能查询 `shop_id_tag IN (当前店铺及下级店铺)` 的资产钱包(由 `ApplyShopTagFilter` 自动过滤)
- 企业账号Handler 层直接返回 403禁止访问
#### Scenario: 平台用户查询卡钱包概况
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡存在钱包记录,余额 100 元,冻结 0 元
- **THEN** 系统返回 200`balance=10000``frozen_balance=0``available_balance=10000``status=1``status_text="正常"`
#### Scenario: 代理账号查询下级资产钱包
- **WHEN** 代理账号shop_id=10请求 `GET /api/admin/assets/device/789/wallet`,该设备的 `shop_id_tag` 在该代理的下级店铺范围内
- **THEN** 系统返回 200返回该设备的钱包详情
#### Scenario: 代理账号查询越权资产钱包
- **WHEN** 代理账号shop_id=10请求 `GET /api/admin/assets/card/999/wallet`,该卡的 `shop_id_tag` 不在该代理的下级店铺范围内
- **THEN** 系统返回 404错误消息为"该资产暂无钱包记录"(不区分"无权"与"不存在"
#### Scenario: 企业账号请求被拒绝
- **WHEN** 企业账号请求 `GET /api/admin/assets/card/456/wallet`
- **THEN** 系统返回 403错误消息为"企业账号无权查看钱包信息"
#### Scenario: 资产无钱包记录
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡尚未创建钱包(未充值过)
- **THEN** 系统返回 404错误消息为"该资产暂无钱包记录"
---
### Requirement: Admin 端查询资产钱包流水列表
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet/transactions` 接口,允许平台用户和代理账号分页查询指定资产的钱包收支流水,每条流水包含可跳转的来源编号。
**接口规格**
- 路径参数:同上
- 查询参数:`page`(默认 1`page_size`(默认 20最大 100`transaction_type`(可选过滤)、`start_time`(可选)、`end_time`(可选)
- 流水按 `created_at` 倒序排列
- 每条流水返回:`id``transaction_type``transaction_type_text``amount``balance_before``balance_after``reference_type``reference_no``remark``created_at`
**来源编号跳转规则**
- `reference_type = "recharge"``reference_no` 为充值单号(`CRCH…`),前端可跳转至充值单详情
- `reference_type = "order"``reference_no` 为订单号(`ORD…`),前端可跳转至订单详情
**权限规则**:与钱包概况接口相同
#### Scenario: 查询充值和扣款流水
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?page=1&page_size=20`,该卡有 1 条充值流水100 元)和 1 条扣款流水(-30 元)
- **THEN** 系统返回 200`total=2`,按时间倒序返回两条记录,充值流水 `amount=10000``reference_type="recharge"``reference_no="CRCH20260309001"`;扣款流水 `amount=-3000``reference_type="order"``reference_no="ORD20260310001"`
#### Scenario: 按交易类型过滤
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?transaction_type=recharge`
- **THEN** 系统只返回 `transaction_type="recharge"` 的流水记录
#### Scenario: 分页超出范围
- **WHEN** 请求 `page_size=200`(超过最大值 100
- **THEN** 系统返回 400错误消息为参数验证失败
#### Scenario: 资产无流水记录
- **WHEN** 平台用户请求某资产的流水列表,该资产钱包存在但尚无任何流水
- **THEN** 系统返回 200`list=[]``total=0`

View File

@@ -0,0 +1,103 @@
## MODIFIED Requirements
### Requirement: 卡钱包实体定义
系统 SHALL 定义资产钱包AssetWallet实体管理物联网卡和设备级别的钱包支持资源转手场景。原 `CardWallet` / `tb_card_wallet` 全量改名为 `AssetWallet` / `tb_asset_wallet`
**核心概念**
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
- **设备钱包**归属设备含1-4张卡设备的多张卡共享钱包设备转手时钱包跟着设备走
**实体字段(与原 CardWallet 完全一致,仅表名改变)**
- `id`:钱包 ID主键BIGINT自增
- `resource_type`资源类型VARCHAR(20),枚举值:"iot_card" | "device"
- `resource_id`:资源 IDBIGINT
- `balance`余额BIGINT单位默认 0
- `frozen_balance`冻结余额BIGINT单位默认 0
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 2-冻结 3-关闭,默认 1
- `version`版本号INT乐观锁
- `shop_id_tag`:店铺 ID 标签(多租户过滤)
- `enterprise_id_tag`:企业 ID 标签(可空)
- `created_at` / `updated_at` / `deleted_at`
**表名变更**`tb_card_wallet``tb_asset_wallet`
#### Scenario: 创建物联网卡钱包
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录,为该卡充值
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet``resource_type` 为 "iot_card"`resource_id` 为卡 ID
#### Scenario: 创建设备钱包
- **WHEN** 个人客户通过设备号登录,为设备充值
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet``resource_type` 为 "device",设备的所有卡共享该钱包
#### Scenario: 计算可用余额
- **WHEN** 钱包余额 10000 分,冻结余额 3000 分
- **THEN** 可用余额 = 7000 分
#### Scenario: 防止同一资源重复创建钱包
- **WHEN** 物联网卡ID=100已有钱包尝试再次创建
- **THEN** 系统拒绝,返回错误"该资源已存在钱包"
---
### Requirement: 资产钱包交易记录
系统 SHALL 记录所有资产钱包余额变动,包括充值、套餐扣费、退款,确保完整收支审计追踪。原 `CardWalletTransaction` / `tb_card_wallet_transaction` 全量改名为 `AssetWalletTransaction` / `tb_asset_wallet_transaction`,同时 `reference_id (bigint)` 字段改为 `reference_no (varchar 50)`
**实体字段**
- `id`:交易记录 ID主键
- `asset_wallet_id`:资产钱包 ID关联 `tb_asset_wallet.id`,原 `card_wallet_id`
- `resource_type`:资源类型(冗余字段)
- `resource_id`:资源 ID冗余字段
- `user_id`:操作人用户 ID
- `transaction_type`:交易类型(`recharge` / `deduct` / `refund`
- `amount`:变动金额(分,充值为正,扣款/退款为负)
- `balance_before`:变动前余额(分)
- `balance_after`:变动后余额(分)
- `status`交易状态1-成功 2-失败 3-处理中)
- `reference_type`:关联业务类型(`recharge``order`,可空)
- `reference_no`:关联业务编号,存储充值单号(`CRCH…`)或订单号(`ORD…`VARCHAR(50),可空)— **原字段 `reference_id (bigint)` 改名并变更类型**
- `remark`备注TEXT可空
- `metadata`扩展信息JSONB可空
- `creator`:创建人 ID
- `shop_id_tag` / `enterprise_id_tag`:多租户标签
**表名变更**`tb_card_wallet_transaction``tb_asset_wallet_transaction`
**字段变更**`reference_id bigint``reference_no varchar(50)`
#### Scenario: 充值写入流水记录
- **WHEN** 个人客户完成充值(充值单号 CRCH20260309001金额 100 元),充值回调成功
- **THEN** 系统在 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="recharge"``amount=10000``reference_type="recharge"``reference_no="CRCH20260309001"`
#### Scenario: 钱包支付套餐写入扣款流水
- **WHEN** 个人客户使用钱包支付套餐订单(订单号 ORD20260310001金额 30 元),`WalletPay` 执行成功
- **THEN** 系统在同一事务内向 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="deduct"``amount=-3000``reference_type="order"``reference_no="ORD20260310001"``balance_before` 为扣款前余额,`balance_after` = `balance_before - 3000`
#### Scenario: 充值流水 reference_no 格式
- **WHEN** 系统写入充值流水
- **THEN** `reference_no` 存储充值单号(格式:`CRCH` + 时间戳 + 随机数),而非数据库主键 ID
#### Scenario: 扣款流水 reference_no 格式
- **WHEN** 系统写入扣款流水
- **THEN** `reference_no` 存储订单号(格式:`ORD` + 时间戳 + 6位随机数而非数据库主键 ID
---
### Requirement: 充值记录表改名
系统 SHALL 将原 `tb_card_recharge_record` 表重命名为 `tb_asset_recharge_record`,对应 Go 类型由 `CardRechargeRecord` 改名为 `AssetRechargeRecord`。H5 充值接口 JSON 响应字段 `wallet_id` 不变(保持向后兼容)。
#### Scenario: H5 充值接口字段不变
- **WHEN** 前端调用 `GET /api/h5/wallets/recharges/:id`,充值记录关联的钱包 ID 为 123
- **THEN** 响应 JSON 中 `wallet_id` 仍为 `123`JSON 字段名不变(仅 Go 内部字段名从 `CardWalletID` 改为 `AssetWalletID`

View File

@@ -0,0 +1,157 @@
## 1. 数据库迁移(先行)
- [x] 1.1 创建迁移文件:`tb_card_wallet``tb_asset_wallet``tb_card_wallet_transaction``tb_asset_wallet_transaction``tb_card_recharge_record``tb_asset_recharge_record`(三张表在同一个迁移文件中完成)
- [x] 1.2 创建迁移文件:`tb_asset_wallet_transaction.reference_id (bigint, nullable)``reference_no (varchar 50, nullable)`ALTER TABLE RENAME COLUMN + ALTER COLUMN TYPE
- [x] 1.3 执行全部迁移,使用 PostgreSQL MCP 确认三张表改名成功,`reference_no` 字段类型为 varchar(50)
## 2. Model 层全量重命名
- [x] 2.1 重命名 `internal/model/card_wallet.go``internal/model/asset_wallet.go`,文件内所有类型改名:
- `CardWallet``AssetWallet``TableName()` 返回 `"tb_asset_wallet"`
- `CardWalletTransaction``AssetWalletTransaction``TableName()` 返回 `"tb_asset_wallet_transaction"`
- `CardRechargeRecord``AssetRechargeRecord``TableName()` 返回 `"tb_asset_recharge_record"`
- [x] 2.2 更新 `AssetWalletTransaction` 结构体字段:`CardWalletID uint json:"card_wallet_id"``AssetWalletID uint json:"asset_wallet_id"`GORM column tag 同步更新为 `column:asset_wallet_id``ReferenceID *uint json:"reference_id,omitempty"``ReferenceNo *string json:"reference_no,omitempty"`GORM column tag 改为 `column:reference_no;type:varchar(50)`
- [x] 2.3 更新 `AssetRechargeRecord` 结构体字段:`CardWalletID uint json:"card_wallet_id"``AssetWalletID uint json:"asset_wallet_id"`GORM column tag 同步更新)
- [x] 2.4 运行 `go build ./...` 确认 Model 层无编译错误
## 3. Store 层全量重命名
- [x] 3.1 重命名 `internal/store/postgres/card_wallet_store.go``asset_wallet_store.go`,类型 `CardWalletStore``AssetWalletStore`,构造函数 `NewCardWalletStore``NewAssetWalletStore`,方法内 `model.CardWallet``model.AssetWallet`
- [x] 3.2 重命名 `internal/store/postgres/card_wallet_transaction_store.go``asset_wallet_transaction_store.go`,类型 `CardWalletTransactionStore``AssetWalletTransactionStore`,构造函数及方法内 Model 引用同步更新
- [x] 3.3 重命名 `internal/store/postgres/card_recharge_store.go``asset_recharge_store.go`,类型 `CardRechargeStore``AssetRechargeStore`,构造函数及方法内 Model 引用同步更新
- [x] 3.4 运行 `go build ./...` 确认 Store 层无编译错误
## 4. Bootstrap 层更新
- [x] 4.1 更新 `internal/bootstrap/stores.go`:字段名 `CardWallet``AssetWallet``CardWalletTransaction``AssetWalletTransaction``CardRecharge``AssetRecharge`;构造函数调用同步更新为 `NewAssetWalletStore``NewAssetWalletTransactionStore``NewAssetRechargeStore`
- [x] 4.2 更新 `internal/bootstrap/services.go`:依赖注入中所有 `s.CardWallet*` 引用改为 `s.AssetWallet*``s.CardRecharge` 改为 `s.AssetRecharge`
- [x] 4.3 运行 `go build ./...` 确认 bootstrap 层无编译错误
## 5. 常量层更新
- [x] 5.1 更新 `pkg/constants/redis.go``RedisCardWalletBalanceKey``RedisAssetWalletBalanceKey`,函数体不变,仅函数名改变
- [x] 5.2 全局搜索 `RedisCardWalletBalanceKey` 调用处card_wallet_store.go替换为 `RedisAssetWalletBalanceKey`
## 6. Service 层适配order service
- [x] 6.1 更新 `internal/service/order/service.go`:结构体字段 `cardWalletStore *postgres.CardWalletStore``assetWalletStore *postgres.AssetWalletStore`,构造函数参数及所有调用点同步更新
- [x] 6.2 在 `WalletPay` 卡钱包支付路径(`resourceType != "shop"` 分支)中,扣款成功后在同一事务内补写 `AssetWalletTransaction` 扣款流水:
- 在事务内扣款前记录 `balanceBefore = wallet.Balance`
- 扣款成功(`RowsAffected == 1`)后,`INSERT` 一条 `AssetWalletTransaction``AssetWalletID=wallet.ID``ResourceType=resourceType``ResourceID=resourceID``UserID=buyerID``TransactionType="deduct"``Amount=-order.TotalAmount``BalanceBefore=balanceBefore``BalanceAfter=balanceBefore-order.TotalAmount``Status=1``ReferenceType=strPtr("order")``ReferenceNo=&order.OrderNo``Remark=strPtr("钱包支付套餐")``ShopIDTag=wallet.ShopIDTag``EnterpriseIDTag=wallet.EnterpriseIDTag`
- [x] 6.3 运行 `go build ./...` 确认 order service 无编译错误
## 7. Service 层适配recharge service
- [x] 7.1 更新 `internal/service/recharge/service.go`:结构体字段及构造函数 `cardWalletStore``assetWalletStore``cardWalletTransactionStore``assetWalletTransactionStore`;所有 Model 引用 `model.CardWallet*``model.AssetWallet*`
- [x] 7.2 更新充值回调写入流水记录处(约第 320 行):`ReferenceID: &recharge.ID``ReferenceNo: &recharge.RechargeNo`(同时删除原 `ReferenceID` 字段赋值)
- [x] 7.3 运行 `go build ./...` 确认 recharge service 无编译错误
## 8. DTO 新增
- [x] 8.1 新建 `internal/model/dto/asset_wallet_dto.go`,定义以下 DTO含所有字段及 `description` tag
**AssetWalletResponse**(钱包概况响应):
- `wallet_id uint``resource_type string``resource_id uint`
- `balance int64``frozen_balance int64``available_balance int64`
- `currency string``status int``status_text string`
- `created_at time.Time``updated_at time.Time`
**AssetWalletTransactionListRequest**(流水列表请求,查询参数):
- `page int`(默认 1`page_size int`(默认 20最大 100
- `transaction_type *string`可选oneof=recharge deduct refund
- `start_time *time.Time`(可选)、`end_time *time.Time`(可选)
**AssetWalletTransactionItem**(单条流水):
- `id uint`
- `transaction_type string``transaction_type_text string`
- `amount int64``balance_before int64``balance_after int64`
- `reference_type *string``reference_no *string`
- `remark *string`
- `created_at time.Time`
**AssetWalletTransactionListResponse**(流水列表响应):
- `list []*AssetWalletTransactionItem`
- `total int64``page int``page_size int``total_pages int`
- [x] 8.2 运行 `lsp_diagnostics` 确认 DTO 无错误
## 9. AssetWalletService 新增
- [x] 9.1 新建 `internal/service/asset_wallet/service.go`,定义 `Service` 结构体,依赖注入:`AssetWalletStore``AssetWalletTransactionStore`
- [x] 9.2 实现 `GetWallet(ctx, assetType string, assetID uint) (*dto.AssetWalletResponse, error)`
-`assetType``card`/`device`)映射到 `resourceType``iot_card`/`device`
- 调用 `AssetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)`
- 组装 `AssetWalletResponse`(计算 `available_balance = balance - frozen_balance`,翻译 `status_text`
- 钱包不存在时返回 `errors.New(errors.CodeNotFound, "该资产暂无钱包记录")`
- [x] 9.3 实现 `ListTransactions(ctx, assetType string, assetID uint, req *dto.AssetWalletTransactionListRequest) (*dto.AssetWalletTransactionListResponse, error)`
-`assetType` 映射为 `resourceType`
- 调用 `AssetWalletTransactionStore.ListByResourceID(ctx, resourceType, assetID, offset, limit)``CountByResourceID` 获取分页数据
- 组装响应:翻译 `transaction_type_text`recharge→充值 / deduct→扣款 / refund→退款计算 `total_pages`
- 如有 `transaction_type` 过滤参数,在 Store 层新增对应过滤方法(或在 Service 层 in-memory 过滤——推荐 Store 层)
- [x] 9.4 运行 `lsp_diagnostics` 确认 Service 无错误
## 10. Store 层新增查询方法
- [x] 10.1 在 `AssetWalletTransactionStore` 中新增 `ListByResourceIDWithFilter(ctx, resourceType string, resourceID uint, transactionType *string, startTime, endTime *time.Time, offset, limit int) ([]*model.AssetWalletTransaction, error)` 方法,支持 `transaction_type`、时间范围过滤,应用 `ApplyShopTagFilter` 数据权限
- [x] 10.2 在 `AssetWalletTransactionStore` 中新增 `CountByResourceIDWithFilter(ctx, resourceType string, resourceID uint, transactionType *string, startTime, endTime *time.Time) (int64, error)` 方法
- [x] 10.3 运行 `lsp_diagnostics` 确认 Store 无错误
## 11. AssetWalletHandler 新增
- [x] 11.1 新建 `internal/handler/admin/asset_wallet.go`,定义 `AssetWalletHandler` 结构体(依赖 `*assetWalletSvc.Service`),实现两个 Handler 方法:
**GetWallet**`GET /api/admin/assets/:asset_type/:id/wallet`
- 检查企业账号:`user_type == UserTypeEnterprise` → 返回 403
- 解析路径参数 `asset_type`(校验为 `card``device`)和 `id`(校验为正整数)
- 调用 `assetWalletSvc.GetWallet(ctx, assetType, id)` → 返回 `response.Success`
**ListTransactions**`GET /api/admin/assets/:asset_type/:id/wallet/transactions`
- 检查企业账号:同上
- 解析路径参数和查询参数(`QueryParser` 绑定 `AssetWalletTransactionListRequest`
- 参数验证:`page_size` 最大 100`transaction_type` 需为合法枚举值
- 调用 `assetWalletSvc.ListTransactions(ctx, assetType, id, &req)` → 返回 `response.Success`
- [x] 11.2 运行 `lsp_diagnostics` 确认 Handler 无编译错误
## 12. 路由注册
- [x] 12.1 在 `internal/routes/asset.go``registerAssetRoutes` 函数末尾追加两条路由(需传入 `*admin.AssetWalletHandler` 参数):
```go
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet", walletHandler.GetWallet, RouteSpec{
Summary: "资产钱包概况",
Description: "查询指定卡或设备的钱包余额概况。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.AssetTypeIDRequest),
Output: new(dto.AssetWalletResponse),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet/transactions", walletHandler.ListTransactions, RouteSpec{
Summary: "资产钱包流水列表",
Description: "分页查询指定资产的钱包收支流水,含充值/扣款来源编号。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.AssetWalletTransactionListRequest),
Output: new(dto.AssetWalletTransactionListResponse),
Auth: true,
})
```
- [x] 12.2 更新 `registerAssetRoutes` 函数签名,增加 `walletHandler *admin.AssetWalletHandler` 参数
- [x] 12.3 更新 `internal/routes/routes.go` 中 `registerAssetRoutes` 的调用处,传入 `AssetWalletHandler`
## 13. Bootstrap 层注册新 Handler/Service
- [x] 13.1 更新 `internal/bootstrap/types.go``Handlers` 结构体新增 `AssetWallet *admin.AssetWalletHandler``Services` 结构体新增 `AssetWallet *assetWalletSvc.Service`(如需独立 service 包则导入)
- [x] 13.2 更新 `internal/bootstrap/services.go`:实例化 `assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction)`
- [x] 13.3 更新 `internal/bootstrap/handlers.go`:实例化 `admin.NewAssetWalletHandler(svcs.AssetWallet)`
- [x] 13.4 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go``handlers.AssetWallet = admin.NewAssetWalletHandler(nil)`(文档生成器注册)
- [x] 13.5 运行 `go build ./...` 全量确认无编译错误
## 14. 文档和最终验收
- [x] 14.1 运行 `go run cmd/gendocs/main.go` 确认两个新接口(资产钱包概况、资产钱包流水列表)出现在 OpenAPI 文档中
- [x] 14.2 使用 PostgreSQL MCP 验证三张表改名成功:`tb_asset_wallet`、`tb_asset_wallet_transaction`(含 `reference_no varchar(50)` 字段)、`tb_asset_recharge_record`
- [x] 14.3 使用 PostgreSQL MCP 或 curl 验证H5 充值接口 `GET /api/h5/wallets/recharges/:id` 响应中 `wallet_id` 字段仍正常返回
- [x] 14.4 运行 `go build ./...` 全量确认无编译错误
- [x] 14.5 tasks.md 全部任务标记完成

View File

@@ -0,0 +1,84 @@
# asset-wallet-query Specification
## Purpose
Admin 端资产钱包查询,允许平台用户和代理账号查询指定物联网卡或设备的钱包余额概况及收支流水,流水包含可跳转的来源编号(充值单号 / 订单号)。
## Requirements
### Requirement: Admin 端查询资产钱包概况
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet` 接口,允许平台用户和代理账号查询指定卡或设备的钱包余额概况。
**接口规格**
- 路径参数 `asset_type``card``device`
- 路径参数 `id`:资产数据库 IDuint
- 无请求体
- 返回字段:`wallet_id``resource_type``resource_id``balance``frozen_balance``available_balance``currency``status``status_text``created_at``updated_at`
**权限规则**
- 平台用户/超级管理员:可查询所有资产钱包
- 代理账号:只能查询 `shop_id_tag IN (当前店铺及下级店铺)` 的资产钱包(由 `ApplyShopTagFilter` 自动过滤)
- 企业账号Handler 层直接返回 403禁止访问
#### Scenario: 平台用户查询卡钱包概况
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡存在钱包记录,余额 100 元,冻结 0 元
- **THEN** 系统返回 200`balance=10000``frozen_balance=0``available_balance=10000``status=1``status_text="正常"`
#### Scenario: 代理账号查询下级资产钱包
- **WHEN** 代理账号shop_id=10请求 `GET /api/admin/assets/device/789/wallet`,该设备的 `shop_id_tag` 在该代理的下级店铺范围内
- **THEN** 系统返回 200返回该设备的钱包详情
#### Scenario: 代理账号查询越权资产钱包
- **WHEN** 代理账号shop_id=10请求 `GET /api/admin/assets/card/999/wallet`,该卡的 `shop_id_tag` 不在该代理的下级店铺范围内
- **THEN** 系统返回 404错误消息为"该资产暂无钱包记录"(不区分"无权"与"不存在"
#### Scenario: 企业账号请求被拒绝
- **WHEN** 企业账号请求 `GET /api/admin/assets/card/456/wallet`
- **THEN** 系统返回 403错误消息为"企业账号无权查看钱包信息"
#### Scenario: 资产无钱包记录
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡尚未创建钱包(未充值过)
- **THEN** 系统返回 404错误消息为"该资产暂无钱包记录"
---
### Requirement: Admin 端查询资产钱包流水列表
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet/transactions` 接口,允许平台用户和代理账号分页查询指定资产的钱包收支流水,每条流水包含可跳转的来源编号。
**接口规格**
- 路径参数:同上
- 查询参数:`page`(默认 1`page_size`(默认 20最大 100`transaction_type`(可选过滤)、`start_time`(可选)、`end_time`(可选)
- 流水按 `created_at` 倒序排列
- 每条流水返回:`id``transaction_type``transaction_type_text``amount``balance_before``balance_after``reference_type``reference_no``remark``created_at`
**来源编号跳转规则**
- `reference_type = "recharge"``reference_no` 为充值单号(`CRCH…`),前端可跳转至充值单详情
- `reference_type = "order"``reference_no` 为订单号(`ORD…`),前端可跳转至订单详情
**权限规则**:与钱包概况接口相同
#### Scenario: 查询充值和扣款流水
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?page=1&page_size=20`,该卡有 1 条充值流水100 元)和 1 条扣款流水(-30 元)
- **THEN** 系统返回 200`total=2`,按时间倒序返回两条记录,充值流水 `amount=10000``reference_type="recharge"``reference_no="CRCH20260309001"`;扣款流水 `amount=-3000``reference_type="order"``reference_no="ORD20260310001"`
#### Scenario: 按交易类型过滤
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?transaction_type=recharge`
- **THEN** 系统只返回 `transaction_type="recharge"` 的流水记录
#### Scenario: 分页超出范围
- **WHEN** 请求 `page_size=200`(超过最大值 100
- **THEN** 系统返回 400错误消息为参数验证失败
#### Scenario: 资产无流水记录
- **WHEN** 平台用户请求某资产的流水列表,该资产钱包存在但尚无任何流水
- **THEN** 系统返回 200`list=[]``total=0`

View File

@@ -1,178 +1,121 @@
# card-wallet Specification # card-wallet Specification
## Purpose ## Purpose
钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。 资产钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
## ADDED Requirements ## Requirements
### Requirement: 钱包实体定义 ### Requirement: 资产钱包实体定义
系统 SHALL 定义钱包(CardWallet实体管理物联网卡和设备级别的钱包支持资源转手场景。 系统 SHALL 定义资产钱包(AssetWallet实体管理物联网卡和设备级别的钱包支持资源转手场景。`CardWallet` / `tb_card_wallet` 全量改名为 `AssetWallet` / `tb_asset_wallet`
**核心概念** **核心概念**
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走 - **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
- **设备钱包**归属设备含1-4张卡设备的多张卡共享钱包设备转手时钱包跟着设备走 - **设备钱包**归属设备含1-4张卡设备的多张卡共享钱包设备转手时钱包跟着设备走
**实体字段** **实体字段(与原 CardWallet 完全一致,仅表名改变)**
- `id`:钱包 ID主键BIGINT自增 - `id`:钱包 ID主键BIGINT自增
- `resource_type`资源类型VARCHAR(20),枚举值:"iot_card"-物联网卡 | "device"-设备,唯一约束之一 - `resource_type`资源类型VARCHAR(20),枚举值:"iot_card" | "device"
- `resource_id`:资源 IDBIGINT,关联 tb_iot_card.id 或 tb_device.id唯一约束之一 - `resource_id`:资源 IDBIGINT
- `balance`余额BIGINT单位默认 0 0 - `balance`余额BIGINT单位默认 0
- `frozen_balance`冻结余额BIGINT单位默认 0 0 - `frozen_balance`冻结余额BIGINT单位默认 0
- `currency`币种VARCHAR(10),默认 "CNY" - `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 2-冻结 3-关闭,默认 1 - `status`钱包状态INT1-正常 2-冻结 3-关闭,默认 1
- `version`版本号INT默认 0乐观锁字段用于防止并发扣款 - `version`版本号INT乐观锁
- `shop_id_tag`:店铺 ID 标签(BIGINT多租户过滤 - `shop_id_tag`:店铺 ID 标签(多租户过滤)
- `enterprise_id_tag`:企业 ID 标签(BIGINT多租户过滤用可空) - `enterprise_id_tag`:企业 ID 标签(可空)
- `created_at`创建时间TIMESTAMP自动填充 - `created_at` / `updated_at` / `deleted_at`
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除 **表名变更**`tb_card_wallet``tb_asset_wallet`
**唯一约束**`(resource_type, resource_id)``deleted_at IS NULL` 条件下唯一 **唯一约束**`(resource_type, resource_id)``deleted_at IS NULL` 条件下唯一
**可用余额计算**:可用余额 = balance - frozen_balance **可用余额计算**:可用余额 = balance - frozen_balance
**表名**`tb_card_wallet`
#### Scenario: 创建物联网卡钱包 #### Scenario: 创建物联网卡钱包
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 - **WHEN** 个人客户通过 ICCID "8986001234567890" 登录,为该卡充值
- **THEN** 系统创建钱包记录,`resource_type` 为 "iot_card"`resource_id` 为卡 ID`balance` 为 0`status` 为 1正常 - **THEN** 系统创建钱包记录写入 `tb_asset_wallet``resource_type` 为 "iot_card"`resource_id` 为卡 ID
#### Scenario: 创建设备钱包 #### Scenario: 创建设备钱包
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 - **WHEN** 个人客户通过设备号登录,为设备充值
- **THEN** 系统创建钱包记录,`resource_type` 为 "device"`resource_id` 为设备 ID设备的 3 张卡共享该钱包 - **THEN** 系统创建钱包记录写入 `tb_asset_wallet``resource_type` 为 "device"设备的所有卡共享该钱包
#### Scenario: 计算可用余额 #### Scenario: 计算可用余额
- **WHEN** 钱包余额 10000 分100 元),冻结余额 3000 分30 元) - **WHEN** 钱包余额 10000 分,冻结余额 3000 分
- **THEN** 系统计算可用余额 7000 分70 元) - **THEN** 可用余额 = 7000 分
#### Scenario: 防止同一资源创建重复钱包 #### Scenario: 防止同一资源重复创建钱包
- **WHEN** 物联网卡ID100已有钱包尝试再次创建钱包 - **WHEN** 物联网卡ID=100已有钱包尝试再次创建
- **THEN** 系统拒绝创建,返回错误信息"该资源已存在钱包" - **THEN** 系统拒绝,返回错误"该资源已存在钱包"
--- ---
### Requirement: 钱包交易记录 ### Requirement: 资产钱包交易记录
系统 SHALL 记录所有钱包余额变动,包括充值、套餐扣费、退款等操作,确保完整审计追踪。 系统 SHALL 记录所有资产钱包余额变动,包括充值、套餐扣费、退款,确保完整收支审计追踪。`CardWalletTransaction` / `tb_card_wallet_transaction` 全量改名为 `AssetWalletTransaction` / `tb_asset_wallet_transaction`,同时 `reference_id (bigint)` 字段改为 `reference_no (varchar 50)`
**实体字段** **实体字段**
- `id`:交易记录 ID主键BIGINT自增 - `id`:交易记录 ID主键
- `card_wallet_id`钱包 IDBIGINT关联 tb_card_wallet.id - `asset_wallet_id`资产钱包 ID关联 `tb_asset_wallet.id`,原 `card_wallet_id`
- `resource_type`:资源类型(VARCHAR(20),冗余字段,便于查询 - `resource_type`:资源类型(冗余字段
- `resource_id`:资源 IDBIGINT冗余字段便于查询 - `resource_id`:资源 ID冗余字段
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id - `user_id`:操作人用户 ID
- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 - `transaction_type`:交易类型(`recharge` / `deduct` / `refund`
- `amount`:变动金额(BIGINT单位正数为增加负数为减少 - `amount`:变动金额(分,充值为正,扣款/退款为负
- `balance_before`:变动前余额(BIGINT单位分) - `balance_before`:变动前余额(分)
- `balance_after`:变动后余额(BIGINT单位分) - `balance_after`:变动后余额(分)
- `status`:交易状态(INT1-成功 2-失败 3-处理中,默认 1 - `status`交易状态1-成功 2-失败 3-处理中)
- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "topup",可空) - `reference_type`:关联业务类型(`recharge``order`,可空)
- `reference_id`:关联业务 IDBIGINT可空 - `reference_no`:关联业务编号,存储充值单号(`CRCH…`)或订单号(`ORD…`VARCHAR(50),可空)— **原字段 `reference_id (bigint)` 改名并变更类型**
- `remark`备注TEXT可空 - `remark`备注TEXT可空
- `metadata`扩展信息JSONB如套餐信息、支付方式等,可空) - `metadata`扩展信息JSONB可空
- `creator`:创建人 IDBIGINT - `creator`:创建人 ID
- `shop_id_tag`:店铺 ID 标签BIGINT多租户过滤用 - `shop_id_tag` / `enterprise_id_tag`:多租户标签
- `enterprise_id_tag`:企业 ID 标签BIGINT多租户过滤用可空
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**表名**`tb_card_wallet_transaction` **表名变更**`tb_card_wallet_transaction``tb_asset_wallet_transaction`
**索引** **字段变更**`reference_id bigint``reference_no varchar(50)`
- `idx_card_tx_wallet (card_wallet_id, created_at)`:按钱包查询交易历史
- `idx_card_tx_resource (resource_type, resource_id, created_at)`:按资源查询交易
- `idx_card_tx_ref (reference_type, reference_id)`:按关联业务查询
- `idx_card_tx_type (transaction_type, created_at)`:按交易类型统计
#### Scenario: 充值创建交易记录 #### Scenario: 充值写入流水记录
- **WHEN** 物联网卡ICCID "8986001234567890")充值 10000 分100 元) - **WHEN** 个人客户完成充值(充值单号 CRCH20260309001金额 100 元),充值回调成功
- **THEN** 系统创建卡钱包交易记录`transaction_type`"recharge"`amount`10000`balance_before` 为 0`balance_after` 为 10000`status` 为 1成功 - **THEN** 系统`tb_asset_wallet_transaction` 写入一条记录`transaction_type="recharge"``amount=10000``reference_type="recharge"``reference_no="CRCH20260309001"`
#### Scenario: 套餐扣费创建交易记录 #### Scenario: 钱包支付套餐写入扣款流水
- **WHEN** 物联网卡ICCID "8986001234567890")购买套餐,钱包支付扣款 3000 分30 元) - **WHEN** 个人客户使用钱包支付套餐订单(订单号 ORD20260310001金额 30 元),`WalletPay` 执行成功
- **THEN** 系统创建卡钱包交易记录`transaction_type`"deduct"`amount`-3000`balance_before` 为 10000`balance_after` 为 7000`reference_type` 为 "order"`reference_id` 为订单 ID - **THEN** 系统在同一事务内向 `tb_asset_wallet_transaction` 写入一条记录`transaction_type="deduct"``amount=-3000``reference_type="order"``reference_no="ORD20260310001"``balance_before` 为扣款前余额,`balance_after` = `balance_before - 3000`
#### Scenario: 订单退款创建交易记录 #### Scenario: 充值流水 reference_no 格式
- **WHEN** 物联网卡订单ID 为 1001退款 3000 分30 元) - **WHEN** 系统写入充值流水
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "refund"`amount` 为 3000`balance_before` 为 7000`balance_after` 为 10000`reference_type` 为 "order"`reference_id` 为 1001 - **THEN** `reference_no` 存储充值单号(格式:`CRCH` + 时间戳 + 随机数),而非数据库主键 ID
#### Scenario: 按资源查询交易历史 #### Scenario: 扣款流水 reference_no 格式
- **WHEN** 个人客户查询物联网卡ICCID "8986001234567890")的交易历史 - **WHEN** 系统写入扣款流水
- **THEN** 系统使用索引 `idx_card_tx_resource` 查询,返回该卡的所有钱包交易记录,按 `created_at` 降序排序 - **THEN** `reference_no` 存储订单号(格式:`ORD` + 时间戳 + 6位随机数而非数据库主键 ID
--- ---
### Requirement: 充值记录管理 ### Requirement: 充值记录表改名
系统 SHALL 记录所有卡充值操作,包括充值订单号、金额、支付方式、支付状态等信息 系统 SHALL 将原 `tb_card_recharge_record` 表重命名为 `tb_asset_recharge_record`,对应 Go 类型由 `CardRechargeRecord` 改名为 `AssetRechargeRecord`。H5 充值接口 JSON 响应字段 `wallet_id` 不变(保持向后兼容)
**实体字段** #### Scenario: H5 充值接口字段不变
- `id`:充值记录 ID主键BIGINT自增
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `card_wallet_id`:卡钱包 IDBIGINT关联 tb_card_wallet.id
- `resource_type`资源类型VARCHAR(20),冗余字段)
- `resource_id`:资源 IDBIGINT冗余字段
- `recharge_no`充值订单号VARCHAR(50)唯一格式CRCH+时间戳+随机数)
- `amount`充值金额BIGINT单位≥ 100
- `payment_method`支付方式VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信)
- `payment_channel`支付渠道VARCHAR(50),可空)
- `payment_transaction_id`第三方支付交易号VARCHAR(100),可空)
- `status`充值状态INT1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1
- `paid_at`支付时间TIMESTAMP可空
- `completed_at`完成时间TIMESTAMP可空
- `shop_id_tag`:店铺 ID 标签BIGINT多租户过滤用
- `enterprise_id_tag`:企业 ID 标签BIGINT多租户过滤用可空
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**表名**`tb_card_recharge_record` - **WHEN** 前端调用 `GET /api/h5/wallets/recharges/:id`,充值记录关联的钱包 ID 为 123
- **THEN** 响应 JSON 中 `wallet_id` 仍为 `123`JSON 字段名不变(仅 Go 内部字段名从 `CardWalletID` 改为 `AssetWalletID`
**充值金额限制**
- 最小充值金额100 分1 元)
- 最大充值金额10000000 分100000 元)
**索引**
- `idx_card_recharge_user (user_id, created_at)`:按用户查询充值记录
- `idx_card_recharge_resource (resource_type, resource_id, created_at)`:按资源查询充值记录
- `idx_card_recharge_status (status, created_at)`:按状态过滤充值记录
- `idx_card_recharge_no (recharge_no)`:按订单号查询
#### Scenario: 创建卡充值订单
- **WHEN** 个人客户为物联网卡ICCID "8986001234567890")发起充值 10000 分100 元),选择微信支付
- **THEN** 系统创建卡充值记录,生成唯一的 `recharge_no`(如 "CRCH20260224123456789012"`amount` 为 10000`payment_method` 为 "wechat"`status` 为 1待支付`resource_type` 为 "iot_card"
#### Scenario: 充值金额低于最小限制
- **WHEN** 个人客户尝试充值 50 分0.5 元)
- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 1 元"
#### Scenario: 充值支付完成
- **WHEN** 个人客户完成微信支付
- **THEN** 系统将充值记录状态从 1待支付变更为 2已支付记录 `paid_at` 时间和 `payment_transaction_id`
#### Scenario: 充值到账
- **WHEN** 充值记录状态为 2已支付系统处理充值到账
- **THEN** 系统将卡钱包余额增加 10000 分,创建卡钱包交易记录,将充值记录状态变更为 3已完成记录 `completed_at` 时间
--- ---
### Requirement: 钱包余额操作 ### Requirement: 资产钱包余额操作
系统 SHALL 支持钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。 系统 SHALL 支持资产钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。
**操作类型** **操作类型**
- **充值**:增加钱包余额 - **充值**:增加钱包余额
@@ -188,36 +131,36 @@
- 扣款时检查可用余额balance - frozen_balance是否充足 - 扣款时检查可用余额balance - frozen_balance是否充足
- 所有余额变动必须创建交易记录 - 所有余额变动必须创建交易记录
#### Scenario: 钱包充值 #### Scenario: 资产钱包充值
- **WHEN** 钱包当前余额为 10000 分,充值 5000 分 - **WHEN** 资产钱包当前余额为 10000 分,充值 5000 分
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2创建交易记录`transaction_type` 为 "recharge"`amount` 为 5000 - **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2创建交易记录`transaction_type` 为 "recharge"`amount` 为 5000
#### Scenario: 钱包扣款 #### Scenario: 资产钱包扣款
- **WHEN** 钱包当前余额为 15000 分,购买套餐扣款 3000 分 - **WHEN** 资产钱包当前余额为 15000 分,购买套餐扣款 3000 分
- **THEN** 系统检查可用余额15000 - 0 = 15000≥ 3000将钱包余额更新为 12000 分,`version` 从 2 变更为 3创建交易记录`transaction_type` 为 "deduct"`amount` 为 -3000 - **THEN** 系统检查可用余额15000 - 0 = 15000≥ 3000将钱包余额更新为 12000 分,`version` 从 2 变更为 3创建交易记录`transaction_type` 为 "deduct"`amount` 为 -3000
#### Scenario: 余额不足扣款失败 #### Scenario: 余额不足扣款失败
- **WHEN** 钱包当前余额为 2000 分,购买套餐需要扣款 3000 分 - **WHEN** 资产钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
- **THEN** 系统检查可用余额2000 - 0 = 2000< 3000拒绝扣款返回错误信息"余额不足" - **THEN** 系统检查可用余额2000 - 0 = 2000< 3000拒绝扣款返回错误信息"余额不足"
#### Scenario: 并发扣款乐观锁生效 #### Scenario: 并发扣款乐观锁生效
- **WHEN** 钱包当前余额为 10000 分version 为 1两个并发请求同时扣款 3000 分和 5000 分 - **WHEN** 资产钱包当前余额为 10000 分version 为 1两个并发请求同时扣款 3000 分和 5000 分
- **THEN** 第一个请求成功,余额变为 7000 分version 变为 2第二个请求因 version 不匹配失败需重新读取最新余额7000 分)和 version2后重试 - **THEN** 第一个请求成功,余额变为 7000 分version 变为 2第二个请求因 version 不匹配失败需重新读取最新余额7000 分)和 version2后重试
#### Scenario: 订单退款 #### Scenario: 订单退款
- **WHEN** 钱包当前余额为 7000 分,订单退款 3000 分 - **WHEN** 资产钱包当前余额为 7000 分,订单退款 3000 分
- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1创建交易记录`transaction_type` 为 "refund"`amount` 为 3000 - **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1创建交易记录`transaction_type` 为 "refund"`amount` 为 3000
--- ---
### Requirement: 钱包数据校验 ### Requirement: 资产钱包数据校验
系统 SHALL 对钱包数据进行校验,确保数据完整性和一致性。 系统 SHALL 对资产钱包数据进行校验,确保数据完整性和一致性。
**校验规则** **校验规则**
- `resource_type`:必填,枚举值 "iot_card" | "device" - `resource_type`:必填,枚举值 "iot_card" | "device"
@@ -230,29 +173,29 @@
#### Scenario: 创建钱包时 resource_type 无效 #### Scenario: 创建钱包时 resource_type 无效
- **WHEN** 创建钱包,`resource_type` 为 "invalid" - **WHEN** 创建资产钱包,`resource_type` 为 "invalid"
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device" - **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device"
#### Scenario: 创建钱包时 resource_id 无效 #### Scenario: 创建钱包时 resource_id 无效
- **WHEN** 创建钱包,`resource_type` 为 "iot_card"`resource_id` 为 0 - **WHEN** 创建资产钱包,`resource_type` 为 "iot_card"`resource_id` 为 0
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1" - **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
#### Scenario: 冻结余额超过总余额 #### Scenario: 冻结余额超过总余额
- **WHEN** 钱包余额为 10000 分,尝试冻结 15000 分 - **WHEN** 资产钱包余额为 10000 分,尝试冻结 15000 分
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额" - **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
#### Scenario: 余额为负数 #### Scenario: 余额为负数
- **WHEN** 尝试将钱包余额设置为 -10000 分 - **WHEN** 尝试将资产钱包余额设置为 -10000 分
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数" - **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
--- ---
### Requirement: 钱包归属资源转手规则 ### Requirement: 资产钱包归属资源转手规则
系统 SHALL 支持钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。 系统 SHALL 支持资产钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。
**归属规则** **归属规则**
@@ -268,16 +211,16 @@
#### Scenario: 个人客户购买单卡并充值 #### Scenario: 个人客户购买单卡并充值
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分 - **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分
- **THEN** 系统创建钱包记录,`resource_type` 为 "iot_card"`resource_id` 为卡 ID`balance` 为 10000 - **THEN** 系统创建资产钱包记录,`resource_type` 为 "iot_card"`resource_id` 为卡 ID`balance` 为 10000
#### Scenario: 个人客户购买设备并充值 #### Scenario: 个人客户购买设备并充值
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分 - **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分
- **THEN** 系统创建钱包记录,`resource_type` 为 "device"`resource_id` 为设备 ID设备的 3 张卡共享该钱包,`balance` 为 20000 - **THEN** 系统创建资产钱包记录,`resource_type` 为 "device"`resource_id` 为设备 ID设备的 3 张卡共享该钱包,`balance` 为 20000
#### Scenario: 卡转手后新用户查询余额 #### Scenario: 卡转手后新用户查询余额
- **WHEN** 个人客户 A微信 OpenID 为 "wx_a"的卡ICCID 为 "8986001234567890")转手给个人客户 B微信 OpenID 为 "wx_b"钱包余额为 5000 分 - **WHEN** 个人客户 A微信 OpenID 为 "wx_a"的卡ICCID 为 "8986001234567890")转手给个人客户 B微信 OpenID 为 "wx_b"),钱包余额为 5000 分
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用 - **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用
#### Scenario: 设备转手后新用户查询余额 #### Scenario: 设备转手后新用户查询余额
@@ -292,13 +235,13 @@
--- ---
### Requirement: 钱包 Redis 缓存策略 ### Requirement: 资产钱包 Redis 缓存策略
系统 SHALL 使用 Redis 缓存钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。 系统 SHALL 使用 Redis 缓存资产钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
**缓存 Key 定义** **缓存 Key 定义**
- 余额缓存:`card_wallet:balance:{resource_type}:{resource_id}` - 余额缓存:`asset_wallet:balance:{resource_type}:{resource_id}`
- 分布式锁:`card_wallet:lock:{resource_type}:{resource_id}` - 分布式锁:`asset_wallet:lock:{resource_type}:{resource_id}`
**缓存 TTL** **缓存 TTL**
- 余额缓存180 秒3 分钟) - 余额缓存180 秒3 分钟)
@@ -308,11 +251,11 @@
- 余额变动时删除缓存Cache-Aside 模式) - 余额变动时删除缓存Cache-Aside 模式)
- 下次查询时重新加载到缓存 - 下次查询时重新加载到缓存
**常量定义位置**`pkg/constants/wallet.go` **常量定义位置**`pkg/constants/redis.go`
```go ```go
func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string func RedisAssetWalletBalanceKey(resourceType string, resourceID uint) string
func RedisCardWalletLockKey(resourceType string, resourceID uint) string func RedisAssetWalletLockKey(resourceType string, resourceID uint) string
``` ```
#### Scenario: 查询余额时使用缓存 #### Scenario: 查询余额时使用缓存
@@ -323,11 +266,9 @@ func RedisCardWalletLockKey(resourceType string, resourceID uint) string
#### Scenario: 余额变动后删除缓存 #### Scenario: 余额变动后删除缓存
- **WHEN** 物联网卡ID 为 100钱包余额增加 5000 分 - **WHEN** 物联网卡ID 为 100钱包余额增加 5000 分
- **THEN** 系统删除 Redis 缓存 Key `card_wallet:balance:iot_card:100`,下次查询时重新加载 - **THEN** 系统删除 Redis 缓存 Key `asset_wallet:balance:iot_card:100`,下次查询时重新加载
#### Scenario: 使用分布式锁防止并发扣款 #### Scenario: 使用分布式锁防止并发扣款
- **WHEN** 两个并发请求同时尝试从物联网卡ID 为 100钱包扣款 - **WHEN** 两个并发请求同时尝试从物联网卡ID 为 100钱包扣款
- **THEN** 系统使用 Redis 分布式锁 `card_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败 - **THEN** 系统使用 Redis 分布式锁 `asset_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败
---