feat: 钱包系统分离 - 代理钱包与卡钱包完全隔离
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m17s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m17s
## 变更概述 将统一钱包系统拆分为代理钱包和卡钱包两个独立系统,实现数据表和代码层面的完全隔离。 ## 数据库变更 - 新增 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>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
# agent-wallet Specification
|
||||
|
||||
## Purpose
|
||||
代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理钱包实体定义
|
||||
|
||||
系统 SHALL 定义代理钱包(AgentWallet)实体,管理店铺级别的钱包,支持主钱包和分佣钱包两种类型。
|
||||
|
||||
**核心概念**:
|
||||
- **主钱包(main)**:店铺的主要资金账户,用于预充值和购买套餐
|
||||
- **分佣钱包(commission)**:店铺的佣金账户,用于接收分佣和提现
|
||||
|
||||
**实体字段**:
|
||||
- `id`:钱包 ID(主键,BIGINT,自增)
|
||||
- `shop_id`:店铺 ID(BIGINT,关联 tb_shop.id,唯一约束之一)
|
||||
- `wallet_type`:钱包类型(VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一)
|
||||
- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0)
|
||||
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0)
|
||||
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1)
|
||||
- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款)
|
||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用,与 shop_id 相同)
|
||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**唯一约束**:`(shop_id, wallet_type)` 在 `deleted_at IS NULL` 条件下唯一
|
||||
|
||||
**可用余额计算**:可用余额 = balance - frozen_balance
|
||||
|
||||
**表名**:`tb_agent_wallet`
|
||||
|
||||
#### Scenario: 创建店铺主钱包
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)首次充值
|
||||
- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "main",`balance` 为 0,`status` 为 1(正常),`shop_id_tag` 为 10
|
||||
|
||||
#### Scenario: 创建店铺分佣钱包
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)首次获得佣金
|
||||
- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "commission",`balance` 为 0,`status` 为 1(正常)
|
||||
|
||||
#### Scenario: 计算可用余额
|
||||
|
||||
- **WHEN** 代理钱包余额为 100000 分(1000 元),冻结余额为 30000 分(300 元)
|
||||
- **THEN** 系统计算可用余额为 70000 分(700 元)
|
||||
|
||||
#### Scenario: 防止同一店铺创建重复钱包类型
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)已有 wallet_type 为 "main" 的钱包,尝试再次创建 wallet_type 为 "main" 的钱包
|
||||
- **THEN** 系统拒绝创建,返回错误信息"该店铺已存在主钱包"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包交易记录
|
||||
|
||||
系统 SHALL 记录所有代理钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:交易记录 ID(主键,BIGINT,自增)
|
||||
- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id)
|
||||
- `shop_id`:店铺 ID(BIGINT,冗余字段,便于按店铺查询)
|
||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
|
||||
- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少)
|
||||
- `balance_before`:变动前余额(BIGINT,单位:分)
|
||||
- `balance_after`:变动后余额(BIGINT,单位:分)
|
||||
- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1)
|
||||
- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup",可空)
|
||||
- `reference_id`:关联业务 ID(BIGINT,可空)
|
||||
- `remark`:备注(TEXT,可空)
|
||||
- `metadata`:扩展信息(JSONB,如手续费、支付方式等,可空)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用)
|
||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**表名**:`tb_agent_wallet_transaction`
|
||||
|
||||
**索引**:
|
||||
- `idx_agent_tx_wallet (agent_wallet_id, created_at)`:按钱包查询交易历史
|
||||
- `idx_agent_tx_shop (shop_id, created_at)`:按店铺汇总交易
|
||||
- `idx_agent_tx_ref (reference_type, reference_id)`:按关联业务查询
|
||||
- `idx_agent_tx_type (transaction_type, created_at)`:按交易类型统计
|
||||
|
||||
#### Scenario: 充值创建交易记录
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)主钱包充值 100000 分(1000 元)
|
||||
- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 100000,`balance_before` 为 0,`balance_after` 为 100000,`status` 为 1(成功),`shop_id` 为 10
|
||||
|
||||
#### Scenario: 分佣发放创建交易记录
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)的分佣钱包收到佣金 50000 分(500 元)
|
||||
- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "commission",`amount` 为 50000,`balance_before` 为 200000,`balance_after` 为 250000,`reference_type` 为 "commission",`reference_id` 为分佣记录 ID
|
||||
|
||||
#### Scenario: 提现创建交易记录
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)从分佣钱包提现 30000 分(300 元)
|
||||
- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "withdrawal",`amount` 为 -30000,`balance_before` 为 250000,`balance_after` 为 220000,`reference_type` 为 "withdrawal",`reference_id` 为提现申请 ID
|
||||
|
||||
#### Scenario: 按店铺查询交易历史
|
||||
|
||||
- **WHEN** 管理员查询店铺(ID 为 10)的所有钱包交易记录,按时间倒序
|
||||
- **THEN** 系统使用索引 `idx_agent_tx_shop` 查询,返回该店铺的主钱包和分佣钱包的所有交易记录,按 `created_at` 降序排序
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理充值记录管理
|
||||
|
||||
系统 SHALL 记录所有代理充值操作,包括充值订单号、金额、支付方式、支付状态等信息。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:充值记录 ID(主键,BIGINT,自增)
|
||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id)
|
||||
- `shop_id`:店铺 ID(BIGINT,冗余字段,便于查询)
|
||||
- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:ARCH+时间戳+随机数)
|
||||
- `amount`:充值金额(BIGINT,单位:分,≥ 10000)
|
||||
- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下)
|
||||
- `payment_channel`:支付渠道(VARCHAR(50),可空)
|
||||
- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空)
|
||||
- `status`:充值状态(INT,1-待支付 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_agent_recharge_record`
|
||||
|
||||
**充值金额限制**:
|
||||
- 最小充值金额:10000 分(100 元)
|
||||
- 最大充值金额:100000000 分(1000000 元)
|
||||
|
||||
**索引**:
|
||||
- `idx_agent_recharge_user (user_id, created_at)`:按用户查询充值记录
|
||||
- `idx_agent_recharge_shop (shop_id, created_at)`:按店铺查询充值记录
|
||||
- `idx_agent_recharge_status (status, created_at)`:按状态过滤充值记录
|
||||
- `idx_agent_recharge_no (recharge_no)`:按订单号查询
|
||||
|
||||
#### Scenario: 创建代理充值订单
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)的管理员发起充值 100000 分(1000 元),选择支付宝支付
|
||||
- **THEN** 系统创建代理充值记录,生成唯一的 `recharge_no`(如 "ARCH20260224123456789012"),`amount` 为 100000,`payment_method` 为 "alipay",`status` 为 1(待支付),`shop_id` 为 10
|
||||
|
||||
#### Scenario: 充值金额低于最小限制
|
||||
|
||||
- **WHEN** 店铺管理员尝试充值 5000 分(50 元)
|
||||
- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额不能低于 100 元"
|
||||
|
||||
#### Scenario: 充值支付完成
|
||||
|
||||
- **WHEN** 店铺管理员完成支付宝支付
|
||||
- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id`
|
||||
|
||||
#### Scenario: 充值到账
|
||||
|
||||
- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账
|
||||
- **THEN** 系统将代理钱包余额增加 100000 分,创建代理钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包余额操作
|
||||
|
||||
系统 SHALL 支持代理钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。
|
||||
|
||||
**操作类型**:
|
||||
- **充值**:增加钱包余额
|
||||
- **扣款**:减少钱包余额(如购买套餐)
|
||||
- **退款**:增加钱包余额(如订单退款)
|
||||
- **冻结**:将部分余额转为冻结状态(如提现申请中)
|
||||
- **解冻**:将冻结余额转回可用余额(如提现取消)
|
||||
|
||||
**并发控制**:
|
||||
- 使用 `version` 字段实现乐观锁
|
||||
- 每次更新余额时,检查 `version` 是否匹配
|
||||
- 如果 `version` 不匹配,说明有并发更新,操作失败并重试
|
||||
|
||||
**操作约束**:
|
||||
- 扣款时,检查可用余额(balance - frozen_balance)是否充足
|
||||
- 冻结时,检查可用余额是否充足
|
||||
- 所有余额变动必须创建交易记录
|
||||
|
||||
#### Scenario: 代理钱包充值
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 100000 分,充值 50000 分
|
||||
- **THEN** 系统将钱包余额更新为 150000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 50000)
|
||||
|
||||
#### Scenario: 代理钱包扣款
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 150000 分,购买套餐扣款 30000 分
|
||||
- **THEN** 系统检查可用余额(150000 - 0 = 150000)≥ 30000,将钱包余额更新为 120000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -30000)
|
||||
|
||||
#### Scenario: 余额不足扣款失败
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 20000 分,购买套餐需要扣款 30000 分
|
||||
- **THEN** 系统检查可用余额(20000 - 0 = 20000)< 30000,拒绝扣款,返回错误信息"余额不足"
|
||||
|
||||
#### Scenario: 并发扣款乐观锁生效
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 100000 分,version 为 1,两个并发请求同时扣款 30000 分和 50000 分
|
||||
- **THEN** 第一个请求成功,余额变为 70000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(70000 分)和 version(2)后重试
|
||||
|
||||
#### Scenario: 冻结余额用于提现
|
||||
|
||||
- **WHEN** 店铺分佣钱包余额为 100000 分,申请提现 30000 分
|
||||
- **THEN** 系统将钱包的 `frozen_balance` 增加 30000 分,可用余额减少 30000 分,`version` 增加 1
|
||||
|
||||
#### Scenario: 解冻余额(提现取消)
|
||||
|
||||
- **WHEN** 店铺分佣钱包冻结余额为 30000 分,用户取消提现申请
|
||||
- **THEN** 系统将钱包的 `frozen_balance` 减少 30000 分,可用余额增加 30000 分,`version` 增加 1
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包数据校验
|
||||
|
||||
系统 SHALL 对代理钱包数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- `shop_id`:必填,≥ 1,必须是有效的店铺 ID
|
||||
- `wallet_type`:必填,枚举值 "main" | "commission"
|
||||
- `balance`:必填,≥ 0
|
||||
- `frozen_balance`:必填,≥ 0,≤ balance
|
||||
- `currency`:必填,长度 1-10 字符,默认 "CNY"
|
||||
- `status`:必填,枚举值 1-3
|
||||
- `version`:必填,≥ 0
|
||||
|
||||
#### Scenario: 创建钱包时 shop_id 无效
|
||||
|
||||
- **WHEN** 创建代理钱包,`shop_id` 为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"店铺 ID 无效,必须 ≥ 1"
|
||||
|
||||
#### Scenario: 创建钱包时 wallet_type 无效
|
||||
|
||||
- **WHEN** 创建代理钱包,`wallet_type` 为 "invalid"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"钱包类型无效,必须是 main 或 commission"
|
||||
|
||||
#### Scenario: 冻结余额超过总余额
|
||||
|
||||
- **WHEN** 代理钱包余额为 100000 分,尝试冻结 150000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
||||
|
||||
#### Scenario: 余额为负数
|
||||
|
||||
- **WHEN** 尝试将代理钱包余额设置为 -10000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包归属店铺规则
|
||||
|
||||
系统 SHALL 确保代理钱包归属店铺,不支持转手,店铺的多个员工账号共享钱包。
|
||||
|
||||
**归属规则**:
|
||||
- 代理钱包归属店铺(shop_id),不归属个人用户
|
||||
- 同一店铺的所有员工账号共享该店铺的主钱包和分佣钱包
|
||||
- 店铺钱包不支持转手,归属关系固定
|
||||
|
||||
#### Scenario: 店铺的多个员工账号共享钱包
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺主钱包余额为 500000 分
|
||||
- **THEN** 3 个员工账号登录后查询店铺主钱包,余额都是 500000 分,可以共享使用
|
||||
|
||||
#### Scenario: 员工账号只能访问自己店铺的钱包
|
||||
|
||||
- **WHEN** 员工账号(ID 为 201,归属店铺 10)尝试访问店铺 20 的钱包
|
||||
- **THEN** 系统拒绝访问,返回错误信息"无权限访问该店铺的钱包"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包 Redis 缓存策略
|
||||
|
||||
系统 SHALL 使用 Redis 缓存代理钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
|
||||
|
||||
**缓存 Key 定义**:
|
||||
- 余额缓存:`agent_wallet:balance:{shop_id}:{wallet_type}`
|
||||
- 分布式锁:`agent_wallet:lock:{shop_id}:{wallet_type}`
|
||||
|
||||
**缓存 TTL**:
|
||||
- 余额缓存:300 秒(5 分钟)
|
||||
- 分布式锁:10 秒
|
||||
|
||||
**缓存更新策略**:
|
||||
- 余额变动时,删除缓存(Cache-Aside 模式)
|
||||
- 下次查询时重新加载到缓存
|
||||
|
||||
**常量定义位置**:`pkg/constants/wallet.go`
|
||||
|
||||
```go
|
||||
func RedisAgentWalletBalanceKey(shopID uint, walletType string) string
|
||||
func RedisAgentWalletLockKey(shopID uint, walletType string) string
|
||||
```
|
||||
|
||||
#### Scenario: 查询余额时使用缓存
|
||||
|
||||
- **WHEN** 查询店铺(ID 为 10)主钱包余额,缓存中存在该余额
|
||||
- **THEN** 系统直接从 Redis 返回余额,不查询数据库
|
||||
|
||||
#### Scenario: 余额变动后删除缓存
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)主钱包余额增加 50000 分
|
||||
- **THEN** 系统删除 Redis 缓存 Key `agent_wallet:balance:10:main`,下次查询时重新加载
|
||||
|
||||
#### Scenario: 使用分布式锁防止并发冻结
|
||||
|
||||
- **WHEN** 两个并发请求同时尝试冻结店铺(ID 为 10)主钱包的余额
|
||||
- **THEN** 系统使用 Redis 分布式锁 `agent_wallet:lock:10:main`,第一个请求获得锁,第二个请求等待或失败
|
||||
|
||||
---
|
||||
@@ -0,0 +1,333 @@
|
||||
# card-wallet Specification
|
||||
|
||||
## Purpose
|
||||
卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 卡钱包实体定义
|
||||
|
||||
系统 SHALL 定义卡钱包(CardWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。
|
||||
|
||||
**核心概念**:
|
||||
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
|
||||
- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走
|
||||
|
||||
**实体字段**:
|
||||
- `id`:钱包 ID(主键,BIGINT,自增)
|
||||
- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card"-物联网卡 | "device"-设备,唯一约束之一)
|
||||
- `resource_id`:资源 ID(BIGINT,关联 tb_iot_card.id 或 tb_device.id,唯一约束之一)
|
||||
- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0)
|
||||
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0)
|
||||
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||
- `status`:钱包状态(INT,1-正常 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`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id)
|
||||
- `resource_type`:资源类型(VARCHAR(20),冗余字段,便于查询)
|
||||
- `resource_id`:资源 ID(BIGINT,冗余字段,便于查询)
|
||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款)
|
||||
- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少)
|
||||
- `balance_before`:变动前余额(BIGINT,单位:分)
|
||||
- `balance_after`:变动后余额(BIGINT,单位:分)
|
||||
- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1)
|
||||
- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "topup",可空)
|
||||
- `reference_id`:关联业务 ID(BIGINT,可空)
|
||||
- `remark`:备注(TEXT,可空)
|
||||
- `metadata`:扩展信息(JSONB,如套餐信息、支付方式等,可空)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `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`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `card_wallet_id`:卡钱包 ID(BIGINT,关联 tb_card_wallet.id)
|
||||
- `resource_type`:资源类型(VARCHAR(20),冗余字段)
|
||||
- `resource_id`:资源 ID(BIGINT,冗余字段)
|
||||
- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:CRCH+时间戳+随机数)
|
||||
- `amount`:充值金额(BIGINT,单位:分,≥ 100)
|
||||
- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信)
|
||||
- `payment_channel`:支付渠道(VARCHAR(50),可空)
|
||||
- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空)
|
||||
- `status`:充值状态(INT,1-待支付 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 分)和 version(2)后重试
|
||||
|
||||
#### 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`,第一个请求获得锁,第二个请求等待或失败
|
||||
|
||||
---
|
||||
@@ -0,0 +1,78 @@
|
||||
# wallet Specification (Delta)
|
||||
|
||||
## Purpose
|
||||
钱包系统架构变更:废弃统一钱包设计,拆分为 `agent-wallet`(代理钱包)和 `card-wallet`(卡钱包)两个完全独立的系统,实现数据层和代码层的完全隔离。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 钱包实体定义
|
||||
|
||||
**Reason**: 废弃统一钱包设计,拆分为代理钱包(AgentWallet)和卡钱包(CardWallet)两个独立实体,使用独立的数据表。
|
||||
|
||||
**Migration**:
|
||||
- 代理钱包(shop 类型)迁移到 `tb_agent_wallet` 表,参见 `agent-wallet` spec
|
||||
- 卡钱包(iot_card 和 device 类型)迁移到 `tb_card_wallet` 表,参见 `card-wallet` spec
|
||||
- 代码层使用新的 Model:`model.AgentWallet` 和 `model.CardWallet`
|
||||
- 代码层使用新的 Store:`AgentWalletStore` 和 `CardWalletStore`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包明细记录
|
||||
|
||||
**Reason**: 废弃统一交易记录表,拆分为代理钱包交易记录(tb_agent_wallet_transaction)和卡钱包交易记录(tb_card_wallet_transaction)两个独立表。
|
||||
|
||||
**Migration**:
|
||||
- 代理钱包交易记录迁移到 `tb_agent_wallet_transaction` 表,参见 `agent-wallet` spec
|
||||
- 卡钱包交易记录迁移到 `tb_card_wallet_transaction` 表,参见 `card-wallet` spec
|
||||
- 代码层使用新的 Model:`model.AgentWalletTransaction` 和 `model.CardWalletTransaction`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 充值记录管理
|
||||
|
||||
**Reason**: 废弃统一充值记录表,拆分为代理充值记录(tb_agent_recharge_record)和卡充值记录(tb_card_recharge_record)两个独立表。
|
||||
|
||||
**Migration**:
|
||||
- 代理充值记录迁移到 `tb_agent_recharge_record` 表,参见 `agent-wallet` spec
|
||||
- 卡充值记录迁移到 `tb_card_recharge_record` 表,参见 `card-wallet` spec
|
||||
- 代码层使用新的 Model:`model.AgentRechargeRecord` 和 `model.CardRechargeRecord`
|
||||
- 充值服务拆分为 `agent_recharge` 和 `card_recharge` 两个独立 Service
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包余额操作
|
||||
|
||||
**Reason**: 余额操作逻辑拆分到代理钱包和卡钱包两个独立系统,使用各自的 Store 实现。
|
||||
|
||||
**Migration**:
|
||||
- 代理钱包余额操作使用 `AgentWalletStore`,参见 `agent-wallet` spec
|
||||
- 卡钱包余额操作使用 `CardWalletStore`,参见 `card-wallet` spec
|
||||
- 并发控制(乐观锁)机制保持不变,继续使用 `version` 字段
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包数据校验
|
||||
|
||||
**Reason**: 数据校验规则拆分到代理钱包和卡钱包两个独立系统,针对各自的字段设计优化。
|
||||
|
||||
**Migration**:
|
||||
- 代理钱包数据校验:使用 `shop_id` + `wallet_type`,参见 `agent-wallet` spec
|
||||
- 卡钱包数据校验:使用 `resource_type` + `resource_id`,参见 `card-wallet` spec
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包归属资源规则
|
||||
|
||||
**Reason**: 归属规则拆分到代理钱包和卡钱包两个独立系统,业务语义更清晰。
|
||||
|
||||
**Migration**:
|
||||
- 代理钱包归属店铺(shop_id),不支持转手,参见 `agent-wallet` spec
|
||||
- 卡钱包归属资源(iot_card / device),支持转手,参见 `card-wallet` spec
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
无修改的 requirements,所有原有 requirements 均已废弃并在新系统中重新定义。
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user