Files
junhong_cmp_fiber/openspec/specs/agent-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

15 KiB
Raw Blame History

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 为 10wallet_type 为 "main"balance 为 0status 为 1正常shop_id_tag 为 10

Scenario: 创建店铺分佣钱包

  • WHEN 店铺ID 为 10首次获得佣金
  • THEN 系统创建代理钱包记录,shop_id 为 10wallet_type 为 "commission"balance 为 0status 为 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 为 100000balance_before 为 0balance_after 为 100000status 为 1成功shop_id 为 10

Scenario: 分佣发放创建交易记录

  • WHEN 店铺ID 为 10的分佣钱包收到佣金 50000 分500 元)
  • THEN 系统创建代理钱包交易记录,transaction_type 为 "commission"amount 为 50000balance_before 为 200000balance_after 为 250000reference_type 为 "commission"reference_id 为分佣记录 ID

Scenario: 提现创建交易记录

  • WHEN 店铺ID 为 10从分佣钱包提现 30000 分300 元)
  • THEN 系统创建代理钱包交易记录,transaction_type 为 "withdrawal"amount 为 -30000balance_before 为 250000balance_after 为 220000reference_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 为 100000payment_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

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,第一个请求获得锁,第二个请求等待或失败