Files
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

319 lines
15 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.
# 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`,第一个请求获得锁,第二个请求等待或失败
---