Files
junhong_cmp_fiber/openspec/specs/card-wallet/spec.md
huang 18daeae65a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m17s
feat: 钱包系统分离 - 代理钱包与卡钱包完全隔离
## 变更概述
将统一钱包系统拆分为代理钱包和卡钱包两个独立系统,实现数据表和代码层面的完全隔离。

## 数据库变更
- 新增 6 张表:tb_agent_wallet、tb_agent_wallet_transaction、tb_agent_recharge_record、tb_card_wallet、tb_card_wallet_transaction、tb_card_recharge_record
- 删除 3 张旧表:tb_wallet、tb_wallet_transaction、tb_recharge_record
- 代理钱包:按 (shop_id, wallet_type) 唯一标识,支持主钱包和分佣钱包
- 卡钱包:按 (resource_type, resource_id) 唯一标识,支持物联网卡和设备

## 代码变更
- Model 层:新增 AgentWallet、AgentWalletTransaction、AgentRechargeRecord、CardWallet、CardWalletTransaction、CardRechargeRecord 模型
- Store 层:新增 6 个独立 Store,支持事务、乐观锁、Redis 缓存
- Service 层:重构 commission_calculation、commission_withdrawal、order、recharge 等 8 个服务
- Bootstrap 层:更新 Store 和 Service 依赖注入
- 常量层:按钱包类型重新组织常量和 Redis Key 生成函数

## 技术特性
- 乐观锁:使用 version 字段防止并发冲突
- 多租户:支持 shop_id_tag 和 enterprise_id_tag 过滤
- 事务管理:所有余额变动使用事务保证 ACID
- 缓存策略:Cache-Aside 模式,余额变动后删除缓存

## 业务影响
- 代理钱包和卡钱包业务完全隔离,互不影响
- 为独立监控、优化、扩展打下基础
- 提升代理钱包的稳定性和独立性

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 09:51:00 +08:00

334 lines
16 KiB
Markdown
Raw 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
卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
## ADDED Requirements
### Requirement: 卡钱包实体定义
系统 SHALL 定义卡钱包CardWallet实体管理物联网卡和设备级别的钱包支持资源转手场景。
**核心概念**
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
- **设备钱包**归属设备含1-4张卡设备的多张卡共享钱包设备转手时钱包跟着设备走
**实体字段**
- `id`:钱包 ID主键BIGINT自增
- `resource_type`资源类型VARCHAR(20),枚举值:"iot_card"-物联网卡 | "device"-设备,唯一约束之一)
- `resource_id`:资源 IDBIGINT关联 tb_iot_card.id 或 tb_device.id唯一约束之一
- `balance`余额BIGINT单位默认 0≥ 0
- `frozen_balance`冻结余额BIGINT单位默认 0≥ 0
- `currency`币种VARCHAR(10),默认 "CNY"
- `status`钱包状态INT1-正常 2-冻结 3-关闭,默认 1
- `version`版本号INT默认 0乐观锁字段用于防止并发扣款
- `shop_id_tag`:店铺 ID 标签BIGINT多租户过滤用
- `enterprise_id_tag`:企业 ID 标签BIGINT多租户过滤用可空
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`(resource_type, resource_id)``deleted_at IS NULL` 条件下唯一
**可用余额计算**:可用余额 = balance - frozen_balance
**表名**`tb_card_wallet`
#### Scenario: 创建物联网卡钱包
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值
- **THEN** 系统创建卡钱包记录,`resource_type` 为 "iot_card"`resource_id` 为卡 ID`balance` 为 0`status` 为 1正常
#### Scenario: 创建设备钱包
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值
- **THEN** 系统创建卡钱包记录,`resource_type` 为 "device"`resource_id` 为设备 ID设备的 3 张卡共享该钱包
#### Scenario: 计算可用余额
- **WHEN** 卡钱包余额为 10000 分100 元),冻结余额为 3000 分30 元)
- **THEN** 系统计算可用余额为 7000 分70 元)
#### Scenario: 防止同一资源创建重复钱包
- **WHEN** 物联网卡ID 为 100已有钱包尝试再次创建钱包
- **THEN** 系统拒绝创建,返回错误信息"该资源已存在钱包"
---
### Requirement: 卡钱包交易记录
系统 SHALL 记录所有卡钱包余额变动,包括充值、套餐扣费、退款等操作,确保完整的审计追踪。
**实体字段**
- `id`:交易记录 ID主键BIGINT自增
- `card_wallet_id`:卡钱包 IDBIGINT关联 tb_card_wallet.id
- `resource_type`资源类型VARCHAR(20),冗余字段,便于查询)
- `resource_id`:资源 IDBIGINT冗余字段便于查询
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中,默认 1
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "topup",可空)
- `reference_id`:关联业务 IDBIGINT可空
- `remark`备注TEXT可空
- `metadata`扩展信息JSONB如套餐信息、支付方式等可空
- `creator`:创建人 IDBIGINT
- `shop_id_tag`:店铺 ID 标签BIGINT多租户过滤用
- `enterprise_id_tag`:企业 ID 标签BIGINT多租户过滤用可空
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**表名**`tb_card_wallet_transaction`
**索引**
- `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: 充值创建交易记录
- **WHEN** 物联网卡ICCID "8986001234567890")充值 10000 分100 元)
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "recharge"`amount` 为 10000`balance_before` 为 0`balance_after` 为 10000`status` 为 1成功
#### Scenario: 套餐扣费创建交易记录
- **WHEN** 物联网卡ICCID "8986001234567890")购买套餐,钱包支付扣款 3000 分30 元)
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "deduct"`amount` 为 -3000`balance_before` 为 10000`balance_after` 为 7000`reference_type` 为 "order"`reference_id` 为订单 ID
#### Scenario: 订单退款创建交易记录
- **WHEN** 物联网卡订单ID 为 1001退款 3000 分30 元)
- **THEN** 系统创建卡钱包交易记录,`transaction_type` 为 "refund"`amount` 为 3000`balance_before` 为 7000`balance_after` 为 10000`reference_type` 为 "order"`reference_id` 为 1001
#### Scenario: 按资源查询交易历史
- **WHEN** 个人客户查询物联网卡ICCID "8986001234567890")的交易历史
- **THEN** 系统使用索引 `idx_card_tx_resource` 查询,返回该卡的所有钱包交易记录,按 `created_at` 降序排序
---
### Requirement: 卡充值记录管理
系统 SHALL 记录所有卡充值操作,包括充值订单号、金额、支付方式、支付状态等信息。
**实体字段**
- `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`
**充值金额限制**
- 最小充值金额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: 卡钱包余额操作
系统 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 定义**
- 余额缓存:`card_wallet:balance:{resource_type}:{resource_id}`
- 分布式锁:`card_wallet:lock:{resource_type}:{resource_id}`
**缓存 TTL**
- 余额缓存180 秒3 分钟)
- 分布式锁10 秒
**缓存更新策略**
- 余额变动时删除缓存Cache-Aside 模式)
- 下次查询时重新加载到缓存
**常量定义位置**`pkg/constants/wallet.go`
```go
func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string
func RedisCardWalletLockKey(resourceType string, resourceID uint) string
```
#### Scenario: 查询余额时使用缓存
- **WHEN** 查询物联网卡ICCID "8986001234567890")钱包余额,缓存中存在该余额
- **THEN** 系统直接从 Redis 返回余额,不查询数据库
#### Scenario: 余额变动后删除缓存
- **WHEN** 物联网卡ID 为 100钱包余额增加 5000 分
- **THEN** 系统删除 Redis 缓存 Key `card_wallet:balance:iot_card:100`,下次查询时重新加载
#### Scenario: 使用分布式锁防止并发扣款
- **WHEN** 两个并发请求同时尝试从物联网卡ID 为 100钱包扣款
- **THEN** 系统使用 Redis 分布式锁 `card_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败
---