feat: 钱包系统分离 - 代理钱包与卡钱包完全隔离
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:
2026-02-25 09:51:00 +08:00
parent f32d32cd36
commit 18daeae65a
66 changed files with 4420 additions and 1090 deletions

View File

@@ -0,0 +1,318 @@
# agent-wallet Specification
## Purpose
代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。
## ADDED Requirements
### Requirement: 代理钱包实体定义
系统 SHALL 定义代理钱包AgentWallet实体管理店铺级别的钱包支持主钱包和分佣钱包两种类型。
**核心概念**
- **主钱包main**:店铺的主要资金账户,用于预充值和购买套餐
- **分佣钱包commission**:店铺的佣金账户,用于接收分佣和提现
**实体字段**
- `id`:钱包 ID主键BIGINT自增
- `shop_id`:店铺 IDBIGINT关联 tb_shop.id唯一约束之一
- `wallet_type`钱包类型VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一)
- `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多租户过滤用与 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`:代理钱包 IDBIGINT关联 tb_agent_wallet.id
- `shop_id`:店铺 IDBIGINT冗余字段便于按店铺查询
- `user_id`:操作人用户 IDBIGINT关联 tb_account.id
- `transaction_type`交易类型VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
- `amount`变动金额BIGINT单位正数为增加负数为减少
- `balance_before`变动前余额BIGINT单位
- `balance_after`变动后余额BIGINT单位
- `status`交易状态INT1-成功 2-失败 3-处理中,默认 1
- `reference_type`关联业务类型VARCHAR(50),如 "order" | "commission" | "withdrawal" | "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_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`:操作人用户 IDBIGINT关联 tb_account.id
- `agent_wallet_id`:代理钱包 IDBIGINT关联 tb_agent_wallet.id
- `shop_id`:店铺 IDBIGINT冗余字段便于查询
- `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`充值状态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_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 分)和 version2后重试
#### 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`,第一个请求获得锁,第二个请求等待或失败
---