Files

275 lines
13 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.
# card-wallet Specification
## Purpose
资产钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
## 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`
**唯一约束**`(resource_type, resource_id)``deleted_at IS NULL` 条件下唯一
**可用余额计算**:可用余额 = balance - frozen_balance
#### 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`
---
### Requirement: 资产钱包余额操作
系统 SHALL 支持资产钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。
**操作类型**
- **充值**:增加钱包余额
- **扣款**:减少钱包余额(如购买套餐)
- **退款**:增加钱包余额(如订单退款)
**并发控制**
- 使用 `version` 字段实现乐观锁
- 每次更新余额时,检查 `version` 是否匹配
- 如果 `version` 不匹配,说明有并发更新,操作失败并重试
**操作约束**
- 扣款时检查可用余额balance - frozen_balance是否充足
- 所有余额变动必须创建交易记录
#### Scenario: 资产钱包充值
- **WHEN** 资产钱包当前余额为 10000 分,充值 5000 分
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2创建交易记录`transaction_type` 为 "recharge"`amount` 为 5000
#### Scenario: 资产钱包扣款
- **WHEN** 资产钱包当前余额为 15000 分,购买套餐扣款 3000 分
- **THEN** 系统检查可用余额15000 - 0 = 15000≥ 3000将钱包余额更新为 12000 分,`version` 从 2 变更为 3创建交易记录`transaction_type` 为 "deduct"`amount` 为 -3000
#### Scenario: 余额不足扣款失败
- **WHEN** 资产钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
- **THEN** 系统检查可用余额2000 - 0 = 2000< 3000拒绝扣款返回错误信息"余额不足"
#### Scenario: 并发扣款乐观锁生效
- **WHEN** 资产钱包当前余额为 10000 分version 为 1两个并发请求同时扣款 3000 分和 5000 分
- **THEN** 第一个请求成功,余额变为 7000 分version 变为 2第二个请求因 version 不匹配失败需重新读取最新余额7000 分)和 version2后重试
#### Scenario: 订单退款
- **WHEN** 资产钱包当前余额为 7000 分,订单退款 3000 分
- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1创建交易记录`transaction_type` 为 "refund"`amount` 为 3000
---
### Requirement: 资产钱包数据校验
系统 SHALL 对资产钱包数据进行校验,确保数据完整性和一致性。
**校验规则**
- `resource_type`:必填,枚举值 "iot_card" | "device"
- `resource_id`:必填,≥ 1必须是有效的资源 ID
- `balance`:必填,≥ 0
- `frozen_balance`:必填,≥ 0≤ balance
- `currency`:必填,长度 1-10 字符,默认 "CNY"
- `status`:必填,枚举值 1-3
- `version`:必填,≥ 0
#### Scenario: 创建钱包时 resource_type 无效
- **WHEN** 创建资产钱包,`resource_type` 为 "invalid"
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device"
#### Scenario: 创建钱包时 resource_id 无效
- **WHEN** 创建资产钱包,`resource_type` 为 "iot_card"`resource_id` 为 0
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
#### Scenario: 冻结余额超过总余额
- **WHEN** 资产钱包余额为 10000 分,尝试冻结 15000 分
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
#### Scenario: 余额为负数
- **WHEN** 尝试将资产钱包余额设置为 -10000 分
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
---
### Requirement: 资产钱包归属资源转手规则
系统 SHALL 支持资产钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。
**归属规则**
| 资源类型 | ResourceType | 适用场景 | 转手规则 |
|---------|-------------|---------|---------|
| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 |
| 设备 | device | 个人客户购买设备含1-4张卡 | 钱包归属设备,设备的多张卡共享钱包,设备转手时钱包跟着设备走 |
**资源转手场景**
- 物联网卡转手:新用户通过 ICCID 登录后可以看到卡的钱包余额
- 设备转手:新用户通过设备号登录后可以看到设备的钱包余额(包含绑定的所有卡)
#### Scenario: 个人客户购买单卡并充值
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分
- **THEN** 系统创建资产钱包记录,`resource_type` 为 "iot_card"`resource_id` 为卡 ID`balance` 为 10000
#### Scenario: 个人客户购买设备并充值
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分
- **THEN** 系统创建资产钱包记录,`resource_type` 为 "device"`resource_id` 为设备 ID设备的 3 张卡共享该钱包,`balance` 为 20000
#### Scenario: 卡转手后新用户查询余额
- **WHEN** 个人客户 A微信 OpenID 为 "wx_a"的卡ICCID 为 "8986001234567890")转手给个人客户 B微信 OpenID 为 "wx_b"),钱包余额为 5000 分
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用
#### Scenario: 设备转手后新用户查询余额
- **WHEN** 个人客户 A 的设备(设备号 "DEV-001",绑定 3 张卡)转手给个人客户 B设备钱包余额为 15000 分
- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包,余额为 15000 分3 张卡共享该余额
#### Scenario: 设备的多张卡共享钱包
- **WHEN** 设备(设备号 "DEV-001")绑定 3 张卡ICCID 为 "111"、"222"、"333"),设备钱包余额为 20000 分
- **THEN** 用户通过任意一张卡的 ICCID 登录,查询钱包余额都是 20000 分(设备级别钱包)
---
### Requirement: 资产钱包 Redis 缓存策略
系统 SHALL 使用 Redis 缓存资产钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
**缓存 Key 定义**
- 余额缓存:`asset_wallet:balance:{resource_type}:{resource_id}`
- 分布式锁:`asset_wallet:lock:{resource_type}:{resource_id}`
**缓存 TTL**
- 余额缓存180 秒3 分钟)
- 分布式锁10 秒
**缓存更新策略**
- 余额变动时删除缓存Cache-Aside 模式)
- 下次查询时重新加载到缓存
**常量定义位置**`pkg/constants/redis.go`
```go
func RedisAssetWalletBalanceKey(resourceType string, resourceID uint) string
func RedisAssetWalletLockKey(resourceType string, resourceID uint) string
```
#### Scenario: 查询余额时使用缓存
- **WHEN** 查询物联网卡ICCID "8986001234567890")钱包余额,缓存中存在该余额
- **THEN** 系统直接从 Redis 返回余额,不查询数据库
#### Scenario: 余额变动后删除缓存
- **WHEN** 物联网卡ID 为 100钱包余额增加 5000 分
- **THEN** 系统删除 Redis 缓存 Key `asset_wallet:balance:iot_card:100`,下次查询时重新加载
#### Scenario: 使用分布式锁防止并发扣款
- **WHEN** 两个并发请求同时尝试从物联网卡ID 为 100钱包扣款
- **THEN** 系统使用 Redis 分布式锁 `asset_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败