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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-24
|
||||
@@ -0,0 +1,540 @@
|
||||
# 钱包系统分离 - 技术设计
|
||||
|
||||
## Context
|
||||
|
||||
### 当前架构
|
||||
|
||||
当前系统使用统一钱包表设计,所有钱包类型存储在同一张表中:
|
||||
|
||||
```
|
||||
tb_wallet (统一钱包表)
|
||||
├─ resource_type (iot_card / device / shop)
|
||||
├─ resource_id
|
||||
├─ wallet_type (main / commission)
|
||||
├─ balance, frozen_balance
|
||||
└─ version (乐观锁)
|
||||
|
||||
tb_wallet_transaction (统一交易记录)
|
||||
tb_recharge_record (统一充值记录)
|
||||
```
|
||||
|
||||
**代码层面**:
|
||||
- 统一的 `WalletStore`,所有钱包类型共用相同的数据访问方法
|
||||
- 多个 Service 依赖同一个 Store:
|
||||
- `commission_*` Service(代理钱包)
|
||||
- `order` Service(卡钱包)
|
||||
- `recharge` Service(代理+卡)
|
||||
|
||||
### 现存问题
|
||||
|
||||
1. **隔离性不足**:代理钱包和卡钱包在数据层和代码层耦合,某个钱包类型的问题可能影响其他类型
|
||||
2. **性能优化受限**:单表数据量大,索引优化需要兼顾多种查询模式,难以针对性优化
|
||||
3. **业务语义模糊**:代理钱包(店铺级别)和卡钱包(资源级别)在业务上差异很大,但使用同一套字段设计
|
||||
4. **稳定性风险**:代理钱包作为核心资产(涉及资金结算),需要更高的隔离级别
|
||||
|
||||
### 约束条件
|
||||
|
||||
- ✅ 项目处于开发阶段,无生产数据,可直接删除旧表
|
||||
- ✅ 必须遵循 Handler → Service → Store → Model 分层架构
|
||||
- ✅ 禁止使用外键约束和 GORM 关联关系
|
||||
- ✅ 必须使用乐观锁(version 字段)防止并发余额冲突
|
||||
- ✅ 所有常量定义在 `pkg/constants/`
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. **数据层完全隔离**:代理钱包和卡钱包使用独立的数据表,包括交易记录表和充值记录表
|
||||
2. **代码层完全隔离**:独立的 Model、Store,类型不兼容,编译期防止混用
|
||||
3. **业务语义清晰化**:代理钱包简化为 shop_id 主键设计,卡钱包保留 resource_type + resource_id 设计
|
||||
4. **独立优化能力**:两种钱包的索引、缓存策略、监控指标完全独立
|
||||
5. **保持 API 兼容性**:对外接口不变,仅内部实现重构
|
||||
|
||||
### Non-Goals
|
||||
|
||||
1. **不做服务拆分**:不将钱包系统拆分为独立的微服务,仍在单体应用内
|
||||
2. **不引入抽象层**:不创建统一的 `WalletService` 接口,两种钱包完全独立实现
|
||||
3. **不做数据迁移**:当前处于开发阶段,直接删除旧表,不考虑迁移脚本
|
||||
4. **不修改对外 API**:Handler 层接口保持不变,仅内部调用改为新的 Store
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:交易记录表和充值记录表也完全分离
|
||||
|
||||
**决策**:不仅分离钱包主表,交易记录表和充值记录表也按钱包类型分离
|
||||
|
||||
**理由**:
|
||||
- **数据量级差异大**:卡钱包交易记录可能是代理钱包的 100 倍以上(每次套餐扣费都有记录)
|
||||
- **查询隔离**:代理的对账报表不应该被卡交易的查询拖慢
|
||||
- **索引优化独立**:代理钱包的交易查询模式(按店铺、按时间范围)与卡钱包(按资源、按订单)完全不同
|
||||
|
||||
**替代方案**:
|
||||
- ❌ 保留统一交易记录表,通过 `wallet_table_type` 字段区分:隔离不彻底,查询性能受限
|
||||
|
||||
**代价**:
|
||||
- 如果未来需要跨钱包类型的全局交易统计,需要跨表查询(UNION)或创建视图
|
||||
|
||||
---
|
||||
|
||||
### 决策 2:代理钱包简化为 shop_id 主键设计
|
||||
|
||||
**当前设计**(统一表):
|
||||
```sql
|
||||
tb_wallet (
|
||||
resource_type = 'shop',
|
||||
resource_id = shop_id,
|
||||
wallet_type = 'main' | 'commission'
|
||||
)
|
||||
```
|
||||
|
||||
**新设计**(简化):
|
||||
```sql
|
||||
tb_agent_wallet (
|
||||
shop_id,
|
||||
wallet_type = 'main' | 'commission'
|
||||
)
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 代理钱包只归属店铺,不需要 `resource_type` 字段
|
||||
- 简化查询逻辑,直接用 `shop_id` 查询
|
||||
- 类型更明确,编译期防止误用
|
||||
|
||||
**索引设计**:
|
||||
```sql
|
||||
UNIQUE INDEX idx_agent_wallet_shop_type (shop_id, wallet_type, deleted_at)
|
||||
INDEX idx_agent_wallet_status (status)
|
||||
INDEX idx_agent_wallet_shop_tag (shop_id_tag) -- 多租户过滤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 3:卡钱包保留 resource_type + resource_id 设计
|
||||
|
||||
**设计**:
|
||||
```sql
|
||||
tb_card_wallet (
|
||||
resource_type = 'iot_card' | 'device',
|
||||
resource_id,
|
||||
balance, frozen_balance, version
|
||||
)
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 卡钱包需要支持两种资源类型(物联网卡、设备)
|
||||
- 保留灵活性,未来可能增加其他资源类型(如企业设备)
|
||||
- 与现有业务逻辑保持一致
|
||||
|
||||
**索引设计**:
|
||||
```sql
|
||||
UNIQUE INDEX idx_card_wallet_resource (resource_type, resource_id, deleted_at)
|
||||
INDEX idx_card_wallet_status (status)
|
||||
INDEX idx_card_wallet_shop_tag (shop_id_tag) -- 多租户过滤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 4:Model 层类型完全独立
|
||||
|
||||
**设计**:
|
||||
```go
|
||||
// internal/model/agent_wallet.go
|
||||
type AgentWallet struct {
|
||||
gorm.Model
|
||||
ShopID uint
|
||||
WalletType string
|
||||
Balance int64
|
||||
FrozenBalance int64
|
||||
Version int
|
||||
}
|
||||
|
||||
// internal/model/card_wallet.go
|
||||
type CardWallet struct {
|
||||
gorm.Model
|
||||
ResourceType string
|
||||
ResourceID uint
|
||||
Balance int64
|
||||
FrozenBalance int64
|
||||
Version int
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 两个独立类型,编译期防止混用(`AgentWallet` 不能传给 `CardWalletStore`)
|
||||
- 字段设计针对各自业务场景优化
|
||||
- 清晰的类型语义,代码可读性更高
|
||||
|
||||
**替代方案**:
|
||||
- ❌ 共用一个 `Wallet` 基类 + 接口抽象:过度设计,增加复杂度
|
||||
|
||||
---
|
||||
|
||||
### 决策 5:Store 层完全独立
|
||||
|
||||
**设计**:
|
||||
```go
|
||||
// internal/store/postgres/agent_wallet_store.go
|
||||
type AgentWalletStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func (s *AgentWalletStore) GetCommissionWallet(ctx, shopID) (*model.AgentWallet, error)
|
||||
func (s *AgentWalletStore) FreezeBalanceWithTx(ctx, tx, walletID, amount, version) error
|
||||
|
||||
// internal/store/postgres/card_wallet_store.go
|
||||
type CardWalletStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func (s *CardWalletStore) GetByResourceTypeAndID(ctx, resourceType, resourceID) (*model.CardWallet, error)
|
||||
func (s *CardWalletStore) DeductBalanceWithTx(ctx, tx, walletID, amount, version) error
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 方法名更具体(`GetCommissionWallet` vs `GetByResourceTypeAndID`),减少参数传递
|
||||
- 每个 Store 只处理自己的表,职责单一
|
||||
- 独立优化查询逻辑和缓存策略
|
||||
|
||||
**事务处理**:
|
||||
- 所有需要事务的方法接收 `tx *gorm.DB` 参数
|
||||
- 调用方(Service 层)负责开启和提交事务
|
||||
- Store 层只执行数据库操作,不管理事务生命周期
|
||||
|
||||
---
|
||||
|
||||
### 决策 6:充值服务拆分
|
||||
|
||||
**当前**:
|
||||
```go
|
||||
internal/service/recharge/
|
||||
└─ service.go // 处理所有类型钱包的充值
|
||||
```
|
||||
|
||||
**新设计**:
|
||||
```go
|
||||
internal/service/agent_recharge/
|
||||
└─ service.go // 只处理代理钱包充值
|
||||
|
||||
internal/service/card_recharge/
|
||||
└─ service.go // 只处理卡钱包充值
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 代理充值和卡充值的业务流程差异大:
|
||||
- 代理充值:金额限制更高(100元起)、支持线下转账、需要审核
|
||||
- 卡充值:金额限制更低(1元起)、仅支持在线支付、自动到账
|
||||
- 拆分后代码更清晰,避免 if-else 分支判断
|
||||
- 独立部署和监控(如果未来微服务化)
|
||||
|
||||
**Handler 层不变**:
|
||||
- Handler 层根据用户类型调用不同的 Service
|
||||
- 对外 API 接口保持不变
|
||||
|
||||
---
|
||||
|
||||
### 决策 7:Redis Key 按钱包类型隔离
|
||||
|
||||
**当前**:
|
||||
```go
|
||||
wallet:balance:{wallet_id}
|
||||
wallet:lock:{wallet_id}
|
||||
```
|
||||
|
||||
**新设计**:
|
||||
```go
|
||||
agent_wallet:balance:{shop_id}:{wallet_type}
|
||||
agent_wallet:lock:{shop_id}:{wallet_type}
|
||||
|
||||
card_wallet:balance:{resource_type}:{resource_id}
|
||||
card_wallet:lock:{resource_type}:{resource_id}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 从 Key 就能明确区分钱包类型,避免误操作
|
||||
- 独立的 Key 前缀便于监控和清理
|
||||
- 支持针对性的 TTL 策略(代理钱包缓存时间可能更长)
|
||||
|
||||
**常量定义**(`pkg/constants/wallet.go`):
|
||||
```go
|
||||
func RedisAgentWalletBalanceKey(shopID uint, walletType string) string {
|
||||
return fmt.Sprintf("agent_wallet:balance:%d:%s", shopID, walletType)
|
||||
}
|
||||
|
||||
func RedisCardWalletBalanceKey(resourceType string, resourceID uint) string {
|
||||
return fmt.Sprintf("card_wallet:balance:%s:%d", resourceType, resourceID)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 8:索引策略独立优化
|
||||
|
||||
**代理钱包索引**(低频、大金额):
|
||||
```sql
|
||||
-- 主查询:按店铺查询钱包
|
||||
idx_agent_wallet_shop_type (shop_id, wallet_type, deleted_at)
|
||||
|
||||
-- 次要查询:按状态过滤异常钱包
|
||||
idx_agent_wallet_status (status)
|
||||
|
||||
-- 多租户过滤:GORM Callback 需要
|
||||
idx_agent_wallet_shop_tag (shop_id_tag)
|
||||
```
|
||||
|
||||
**卡钱包索引**(高频、小金额):
|
||||
```sql
|
||||
-- 主查询:按资源查询钱包
|
||||
idx_card_wallet_resource (resource_type, resource_id, deleted_at)
|
||||
|
||||
-- 次要查询:按状态过滤
|
||||
idx_card_wallet_status (status)
|
||||
|
||||
-- 多租户过滤
|
||||
idx_card_wallet_shop_tag (shop_id_tag)
|
||||
```
|
||||
|
||||
**交易记录索引**:
|
||||
|
||||
代理钱包交易:
|
||||
```sql
|
||||
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) -- 按交易类型统计
|
||||
```
|
||||
|
||||
卡钱包交易:
|
||||
```sql
|
||||
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) -- 按类型统计
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 决策 9:乐观锁继续使用 version 字段
|
||||
|
||||
**设计**:
|
||||
```go
|
||||
// 扣款时检查 version
|
||||
result := tx.Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND balance >= ? AND version = ?", walletID, amount, currentVersion).
|
||||
Updates(map[string]interface{}{
|
||||
"balance": gorm.Expr("balance - ?", amount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeConcurrentConflict, "余额不足或并发冲突")
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 与现有钱包系统保持一致
|
||||
- 乐观锁适用于读多写少的场景(钱包查询频繁,扣款相对低频)
|
||||
- 相比悲观锁(SELECT FOR UPDATE),性能更好
|
||||
|
||||
**并发处理**:
|
||||
- Service 层捕获 `RowsAffected == 0` 错误,重试 1-2 次
|
||||
- 重试前重新读取最新余额和 version
|
||||
|
||||
---
|
||||
|
||||
### 决策 10:不引入抽象层
|
||||
|
||||
**决策**:不创建统一的 `WalletService` 接口或抽象基类
|
||||
|
||||
**理由**:
|
||||
- 代理钱包和卡钱包的业务场景差异太大,强行抽象反而增加复杂度
|
||||
- Go 推崇组合优于继承,过度抽象违背 Go 惯用法
|
||||
- 两种钱包的方法签名不同(`shop_id` vs `resource_type + resource_id`)
|
||||
|
||||
**替代方案**:
|
||||
- ❌ 创建 `WalletService` 接口,定义 `GetBalance(id WalletID)`:过度抽象,实际调用时仍需类型断言
|
||||
|
||||
**如果未来需要通用逻辑**:
|
||||
- 可以提取为独立的 helper 函数,而不是接口
|
||||
- 例如:`pkg/wallet/helper.go` 中定义 `ValidateAmount(amount int64) error`
|
||||
|
||||
---
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险 1:代码重构引入 Bug
|
||||
|
||||
**风险**:重构涉及多个模块(Model、Store、Service、Bootstrap),可能引入逻辑错误
|
||||
|
||||
**缓解措施**:
|
||||
- 逐步重构,先完成 Model 和 Store,再重构 Service
|
||||
- 每个模块完成后进行编译检查
|
||||
- 手动测试核心流程:
|
||||
- 代理钱包:佣金发放、提现、余额查询
|
||||
- 卡钱包:订单支付、充值、余额查询
|
||||
- 边界场景:余额不足、并发扣款
|
||||
|
||||
---
|
||||
|
||||
### 风险 2:遗漏依赖点导致编译失败
|
||||
|
||||
**风险**:可能有隐藏的依赖点未被发现,删除旧 Model 后编译失败
|
||||
|
||||
**缓解措施**:
|
||||
- 先创建新 Model 和 Store,保留旧代码
|
||||
- 逐步替换依赖点,每次替换后编译验证
|
||||
- 最后删除旧 Model 和 Store,确保编译通过
|
||||
|
||||
---
|
||||
|
||||
### 风险 3:性能回退
|
||||
|
||||
**风险**:新表的索引设计不当,导致查询性能下降
|
||||
|
||||
**缓解措施**:
|
||||
- 索引设计参考现有表,保持覆盖常用查询
|
||||
- 分离后单表数据量减少,预期性能持平或提升
|
||||
- 如有性能问题,可以针对性优化索引(不影响其他钱包类型)
|
||||
|
||||
---
|
||||
|
||||
### 权衡 1:完全分离交易记录表 vs 统一表
|
||||
|
||||
**选择**:完全分离
|
||||
|
||||
**代价**:
|
||||
- 如果未来需要全局交易统计(跨钱包类型),需要 UNION 查询或创建视图
|
||||
- 增加表数量(6 张表 vs 3 张表)
|
||||
|
||||
**收益**:
|
||||
- 查询性能独立优化
|
||||
- 数据量隔离,避免单表过大
|
||||
- 代理钱包的对账查询不受卡钱包高频交易影响
|
||||
|
||||
**结论**:收益大于代价,全局交易统计并非高频需求,可以通过定时汇总解决
|
||||
|
||||
---
|
||||
|
||||
### 权衡 2:不引入抽象层 vs 统一接口
|
||||
|
||||
**选择**:不引入抽象层
|
||||
|
||||
**代价**:
|
||||
- 如果未来需要第三种钱包类型(如企业钱包),需要独立实现,不能复用接口
|
||||
- 代码重复度略高(如余额校验逻辑)
|
||||
|
||||
**收益**:
|
||||
- 代码简单,符合 Go 惯用法
|
||||
- 编译期类型安全,避免接口断言
|
||||
- 每个钱包类型独立演进,互不影响
|
||||
|
||||
**结论**:当前场景不需要抽象层,如果未来真的需要,再重构也不迟(YAGNI 原则)
|
||||
|
||||
---
|
||||
|
||||
### 权衡 3:充值服务拆分 vs 统一服务
|
||||
|
||||
**选择**:拆分为两个独立服务
|
||||
|
||||
**代价**:
|
||||
- 增加 Service 文件数量
|
||||
- Handler 层需要根据用户类型调用不同 Service
|
||||
|
||||
**收益**:
|
||||
- 代码逻辑清晰,避免 if-else 分支
|
||||
- 业务规则独立(金额限制、支付方式、审核流程)
|
||||
- 独立测试和监控
|
||||
|
||||
**结论**:拆分更符合单一职责原则,代价可接受
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 实施步骤
|
||||
|
||||
由于当前处于开发阶段,无需数据迁移,直接重构:
|
||||
|
||||
**阶段 1:数据库层(0.5 天)**
|
||||
1. 编写迁移文件创建 6 张新表
|
||||
2. 本地执行迁移验证表结构
|
||||
3. 删除旧表的迁移文件(`tb_wallet`, `tb_wallet_transaction`, `tb_recharge_record`)
|
||||
|
||||
**阶段 2:Model 和 Store 层(2 天)**
|
||||
1. 创建新 Model:`agent_wallet.go`、`card_wallet.go`
|
||||
2. 创建新 Store:
|
||||
- `agent_wallet_store.go`
|
||||
- `agent_wallet_transaction_store.go`
|
||||
- `agent_recharge_store.go`
|
||||
- `card_wallet_store.go`
|
||||
- `card_wallet_transaction_store.go`
|
||||
- `card_recharge_store.go`
|
||||
3. 更新 `pkg/constants/wallet.go`(常量定义和 Redis Key 函数)
|
||||
4. 保留旧 Model 和 Store(暂不删除)
|
||||
|
||||
**阶段 3:Service 层重构(2 天)**
|
||||
1. 更新 `internal/bootstrap/stores.go`(注册新 Store)
|
||||
2. 重构 `commission_*` Service(改用 `AgentWalletStore`)
|
||||
3. 重构 `order` Service(改用 `CardWalletStore`)
|
||||
4. 拆分 `recharge` Service 为两个独立服务
|
||||
5. 更新 `internal/bootstrap/services.go`(依赖注入)
|
||||
6. 编译验证,逐步替换依赖点
|
||||
|
||||
**阶段 4:清理和测试(1.5 天)**
|
||||
1. 删除旧 Model:`internal/model/wallet.go`
|
||||
2. 删除旧 Store:`wallet_store.go`、`wallet_transaction_store.go`
|
||||
3. 编译检查,确保无引用残留
|
||||
4. 手动测试核心流程
|
||||
5. 数据一致性验证
|
||||
|
||||
**总计**:6 天
|
||||
|
||||
### 回滚策略
|
||||
|
||||
**如果重构失败**(仅适用于代码未合并到主分支前):
|
||||
1. 恢复旧 Model 和 Store 代码
|
||||
2. 恢复旧的数据库表(从备份或重新运行旧迁移)
|
||||
3. 恢复旧的依赖注入配置
|
||||
|
||||
**预防措施**:
|
||||
- 在独立分支上进行重构
|
||||
- 每个阶段完成后提交代码
|
||||
- 保留旧代码直到新代码验证通过
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Q1:是否需要创建数据库视图方便全局统计?
|
||||
|
||||
**场景**:未来可能需要全局交易统计(跨代理钱包和卡钱包)
|
||||
|
||||
**选项**:
|
||||
- **选项 A**:创建 VIEW `v_all_wallet_transactions`(UNION 两张交易表)
|
||||
- **选项 B**:不创建视图,需要时在应用层 UNION 查询
|
||||
- **选项 C**:创建定时任务,汇总到独立的统计表
|
||||
|
||||
**建议**:等到有明确需求时再决定,当前不创建(YAGNI 原则)
|
||||
|
||||
---
|
||||
|
||||
### Q2:监控指标如何实现?
|
||||
|
||||
**场景**:提案中建议新增监控指标(`agent_wallet_error_rate`、`card_wallet_error_rate`)
|
||||
|
||||
**问题**:
|
||||
- 是否在本次重构中实现监控埋点?
|
||||
- 还是只预留接口,后续专门做监控系统?
|
||||
|
||||
**建议**:本次重构不包含监控实现,只确保代码层面可以区分钱包类型(便于未来埋点)
|
||||
|
||||
---
|
||||
|
||||
### Q3:是否需要为 BaseModel 字段添加到新表?
|
||||
|
||||
**当前**:旧表包含 `BaseModel`(`shop_id_tag`、`enterprise_id_tag` 等多租户字段)
|
||||
|
||||
**问题**:新表是否需要保留这些字段?
|
||||
|
||||
**建议**:保留,因为系统使用 GORM Callback 自动过滤多租户数据,这些字段是必需的
|
||||
@@ -0,0 +1,153 @@
|
||||
# 钱包系统分离提案
|
||||
|
||||
## Why
|
||||
|
||||
当前所有钱包类型(代理钱包、卡钱包)混在同一张表 `tb_wallet` 中,通过 `resource_type` 和 `wallet_type` 字段区分。业务方担心未来某个钱包业务的代码问题会影响其他钱包类型,特别是代理钱包作为核心资产需要最高等级的稳定性保障。在系统复杂度增长前,需要提前做预防性架构优化,将代理钱包和卡钱包在数据层和代码层完全隔离。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 数据库层变更
|
||||
|
||||
**新增表**:
|
||||
- `tb_agent_wallet` - 代理钱包主表(店铺级别)
|
||||
- `tb_agent_wallet_transaction` - 代理钱包交易记录表
|
||||
- `tb_agent_recharge_record` - 代理充值记录表
|
||||
- `tb_card_wallet` - 卡钱包主表(物联网卡和设备)
|
||||
- `tb_card_wallet_transaction` - 卡钱包交易记录表
|
||||
- `tb_card_recharge_record` - 卡充值记录表
|
||||
|
||||
**删除表** - **BREAKING**:
|
||||
- `tb_wallet` - 统一钱包表(由新的两张表替代)
|
||||
- `tb_wallet_transaction` - 统一交易记录表(由新的两张表替代)
|
||||
- `tb_recharge_record` - 统一充值记录表(由新的两张表替代)
|
||||
|
||||
### 代码层变更
|
||||
|
||||
**新增 Model**:
|
||||
- `internal/model/agent_wallet.go` - 代理钱包相关 Model(AgentWallet、AgentWalletTransaction、AgentRechargeRecord)
|
||||
- `internal/model/card_wallet.go` - 卡钱包相关 Model(CardWallet、CardWalletTransaction、CardRechargeRecord)
|
||||
|
||||
**删除 Model** - **BREAKING**:
|
||||
- `internal/model/wallet.go` - 统一钱包 Model(由新的两个文件替代)
|
||||
|
||||
**新增 Store**:
|
||||
- `internal/store/postgres/agent_wallet_store.go` - 代理钱包数据访问层
|
||||
- `internal/store/postgres/agent_wallet_transaction_store.go` - 代理钱包交易记录访问层
|
||||
- `internal/store/postgres/agent_recharge_store.go` - 代理充值记录访问层
|
||||
- `internal/store/postgres/card_wallet_store.go` - 卡钱包数据访问层
|
||||
- `internal/store/postgres/card_wallet_transaction_store.go` - 卡钱包交易记录访问层
|
||||
- `internal/store/postgres/card_recharge_store.go` - 卡充值记录访问层
|
||||
|
||||
**删除 Store** - **BREAKING**:
|
||||
- `internal/store/postgres/wallet_store.go` - 统一钱包 Store(由新的 Store 替代)
|
||||
- `internal/store/postgres/wallet_transaction_store.go` - 统一交易记录 Store(由新的 Store 替代)
|
||||
|
||||
**重构 Service**:
|
||||
- `internal/service/commission_*` - 佣金相关服务改用 `AgentWalletStore`
|
||||
- `internal/service/order` - 订单服务改用 `CardWalletStore`
|
||||
- `internal/service/recharge` - 充值服务拆分为代理充值和卡充值两个独立服务
|
||||
|
||||
**更新常量定义**:
|
||||
- `pkg/constants/wallet.go` - 按钱包类型隔离常量定义和 Redis Key 生成函数
|
||||
|
||||
**更新依赖注入**:
|
||||
- `internal/bootstrap/stores.go` - 注册新的 Store 实例
|
||||
- `internal/bootstrap/services.go` - 更新 Service 依赖注入
|
||||
|
||||
### 监控和运维
|
||||
|
||||
**新增监控指标**(建议):
|
||||
- `agent_wallet_transaction_count` - 代理钱包交易量
|
||||
- `agent_wallet_error_rate` - 代理钱包错误率
|
||||
- `card_wallet_transaction_count` - 卡钱包交易量
|
||||
- `card_wallet_error_rate` - 卡钱包错误率
|
||||
|
||||
**告警策略差异化**:
|
||||
- 代理钱包错误率阈值更严格(建议 P0 级别告警)
|
||||
- 卡钱包错误率阈值相对宽松(建议 P1 级别告警)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `agent-wallet`: 代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作
|
||||
- `card-wallet`: 卡钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `wallet`: 需求变更 - 废弃统一钱包设计,拆分为 `agent-wallet` 和 `card-wallet` 两个完全独立的系统,要求在数据表、Model、Store、部分 Service 层面完全隔离
|
||||
|
||||
## Impact
|
||||
|
||||
### 受影响的模块
|
||||
|
||||
| 模块类型 | 受影响组件 | 变更类型 |
|
||||
|---------|-----------|---------|
|
||||
| **数据库** | `tb_wallet`、`tb_wallet_transaction`、`tb_recharge_record` | 删除并替换为 6 张新表 |
|
||||
| **Model** | `internal/model/wallet.go` | 删除并替换为 2 个新文件 |
|
||||
| **Store** | `internal/store/postgres/wallet_*.go` | 删除并替换为 6 个新 Store |
|
||||
| **Service** | `internal/service/commission_*` | 依赖注入改为 `AgentWalletStore` |
|
||||
| **Service** | `internal/service/order` | 依赖注入改为 `CardWalletStore` |
|
||||
| **Service** | `internal/service/recharge` | 拆分为两个独立服务 |
|
||||
| **Bootstrap** | `internal/bootstrap/stores.go`、`services.go` | 更新依赖注入配置 |
|
||||
| **常量** | `pkg/constants/wallet.go` | 按钱包类型重构常量定义 |
|
||||
| **Redis Key** | 钱包相关缓存 Key | 按钱包类型隔离(`agent_wallet:*`、`card_wallet:*`) |
|
||||
|
||||
### API 影响
|
||||
|
||||
**无 API 破坏性变更** - 所有对外 API 接口保持不变,仅内部实现重构
|
||||
|
||||
### 性能影响
|
||||
|
||||
**预期正向影响**:
|
||||
- 代理钱包和卡钱包数据量独立,查询性能提升
|
||||
- 索引优化可针对不同钱包类型的查询模式独立调优
|
||||
- 减少单表数据量,降低锁竞争概率
|
||||
|
||||
### 部署影响
|
||||
|
||||
**当前处于开发阶段**:
|
||||
- 无需数据迁移
|
||||
- 删除旧表,创建新表
|
||||
- 一次性部署代码变更
|
||||
|
||||
**如果未来生产环境部署**(预留方案):
|
||||
- 需要制定数据迁移脚本
|
||||
- 需要停机窗口或在线双写迁移方案
|
||||
|
||||
### 风险评估
|
||||
|
||||
| 风险项 | 风险等级 | 缓解措施 |
|
||||
|-------|---------|---------|
|
||||
| 代码重构引入 Bug | 中 | 充分的手动测试,验证核心流程 |
|
||||
| 遗漏依赖点导致编译失败 | 低 | 编译检查,逐步重构 |
|
||||
| 数据表设计遗漏字段 | 低 | 参考现有 Model 设计,保持字段完整性 |
|
||||
| 性能回退 | 低 | 索引设计参考现有表,预期性能持平或提升 |
|
||||
|
||||
### 测试策略
|
||||
|
||||
**手动测试覆盖**:
|
||||
- 代理钱包:佣金发放、提现、余额查询
|
||||
- 卡钱包:订单支付、充值、余额查询
|
||||
- 边界场景:余额不足、并发扣款、冻结/解冻
|
||||
|
||||
**数据一致性验证**:
|
||||
- 验证代理钱包交易记录的 `balance_before` 和 `balance_after` 准确性
|
||||
- 验证卡钱包交易记录的余额变动准确性
|
||||
- 验证乐观锁(version 字段)在并发场景下的有效性
|
||||
|
||||
### 时间估算
|
||||
|
||||
**预估工作量**:
|
||||
- 数据库迁移文件编写:0.5 天
|
||||
- Model 和 Store 重构:2 天
|
||||
- Service 层重构:2 天
|
||||
- Bootstrap 和常量更新:0.5 天
|
||||
- 手动测试和验证:1 天
|
||||
- **总计:6 天**
|
||||
|
||||
### 依赖和前置条件
|
||||
|
||||
- ✅ 项目处于开发阶段,可直接重构
|
||||
- ✅ 无生产数据,无需迁移方案
|
||||
- ✅ 技术栈符合项目规范(GORM、PostgreSQL、Redis)
|
||||
@@ -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 均已废弃并在新系统中重新定义。
|
||||
|
||||
---
|
||||
@@ -0,0 +1,208 @@
|
||||
# 钱包系统分离 - 实施任务清单
|
||||
|
||||
## 1. 数据库迁移 - 创建新表结构
|
||||
|
||||
- [x] 1.1 创建代理钱包主表迁移文件(`tb_agent_wallet`),包含 shop_id、wallet_type、balance、frozen_balance、version 等字段,添加唯一索引和多租户索引
|
||||
- [x] 1.2 创建代理钱包交易记录表迁移文件(`tb_agent_wallet_transaction`),包含 agent_wallet_id、shop_id、transaction_type、amount、balance_before、balance_after 等字段,添加 4 个查询索引
|
||||
- [x] 1.3 创建代理充值记录表迁移文件(`tb_agent_recharge_record`),包含 recharge_no、amount、payment_method、status 等字段,添加状态和店铺索引
|
||||
- [x] 1.4 创建卡钱包主表迁移文件(`tb_card_wallet`),包含 resource_type、resource_id、balance、frozen_balance、version 等字段,添加唯一索引和多租户索引
|
||||
- [x] 1.5 创建卡钱包交易记录表迁移文件(`tb_card_wallet_transaction`),包含 card_wallet_id、resource_type、resource_id、transaction_type、amount 等字段,添加 4 个查询索引
|
||||
- [x] 1.6 创建卡充值记录表迁移文件(`tb_card_recharge_record`),包含 recharge_no、amount、payment_method、status 等字段,添加状态和资源索引
|
||||
- [x] 1.7 执行数据库迁移,验证 6 张新表创建成功,检查索引和约束
|
||||
|
||||
## 2. 代理钱包系统 - Model 层实现
|
||||
|
||||
- [x] 2.1 创建 `internal/model/agent_wallet.go`,定义 AgentWallet 结构体,包含 ShopID、WalletType、Balance、FrozenBalance、Version、BaseModel 等字段
|
||||
- [x] 2.2 在 `agent_wallet.go` 中定义 AgentWalletTransaction 结构体,包含 AgentWalletID、ShopID、TransactionType、Amount、BalanceBefore、BalanceAfter 等字段
|
||||
- [x] 2.3 在 `agent_wallet.go` 中定义 AgentRechargeRecord 结构体,包含 UserID、AgentWalletID、ShopID、RechargeNo、Amount、PaymentMethod、Status 等字段
|
||||
- [x] 2.4 为所有 Model 实现 TableName() 方法,返回对应的表名(tb_agent_wallet、tb_agent_wallet_transaction、tb_agent_recharge_record)
|
||||
- [x] 2.5 添加中文注释说明每个字段的用途和约束
|
||||
- [x] 2.6 编译验证 Model 定义正确,无语法错误
|
||||
|
||||
## 3. 代理钱包系统 - Store 层实现
|
||||
|
||||
- [x] 3.1 创建 `internal/store/postgres/agent_wallet_store.go`,定义 AgentWalletStore 结构体,包含 db 和 redis 字段
|
||||
- [x] 3.2 实现 `NewAgentWalletStore` 构造函数
|
||||
- [x] 3.3 实现 `GetCommissionWallet(ctx, shopID)` 方法,查询店铺的分佣钱包
|
||||
- [x] 3.4 实现 `GetMainWallet(ctx, shopID)` 方法,查询店铺的主钱包
|
||||
- [x] 3.5 实现 `GetByShopIDAndType(ctx, shopID, walletType)` 方法,根据店铺 ID 和钱包类型查询
|
||||
- [x] 3.6 实现 `GetByID(ctx, id)` 方法,根据钱包 ID 查询
|
||||
- [x] 3.7 实现 `DeductFrozenBalanceWithTx(ctx, tx, walletID, amount)` 方法,从冻结余额扣款(带事务)
|
||||
- [x] 3.8 实现 `UnfreezeBalanceWithTx(ctx, tx, walletID, amount)` 方法,解冻余额到可用余额(带事务)
|
||||
- [x] 3.9 实现 `FreezeBalanceWithTx(ctx, tx, walletID, amount, version)` 方法,冻结余额(带事务,使用乐观锁)
|
||||
- [x] 3.10 实现 `GetShopCommissionSummaryBatch(ctx, shopIDs)` 方法,批量获取店铺佣金钱包汇总
|
||||
- [x] 3.11 创建 `internal/store/postgres/agent_wallet_transaction_store.go`,定义 AgentWalletTransactionStore 结构体
|
||||
- [x] 3.12 实现 `CreateWithTx(ctx, tx, transaction)` 方法,创建代理钱包交易记录(带事务)
|
||||
- [x] 3.13 实现 `ListByShopID(ctx, shopID, offset, limit)` 方法,按店铺查询交易记录(支持分页)
|
||||
- [x] 3.14 创建 `internal/store/postgres/agent_recharge_store.go`,定义 AgentRechargeStore 结构体
|
||||
- [x] 3.15 实现充值记录的 CRUD 方法(Create、GetByRechargeNo、UpdateStatus 等)
|
||||
- [x] 3.16 编译验证 Store 层代码正确,无语法错误
|
||||
|
||||
## 4. 卡钱包系统 - Model 层实现
|
||||
|
||||
- [x] 4.1 创建 `internal/model/card_wallet.go`,定义 CardWallet 结构体,包含 ResourceType、ResourceID、Balance、FrozenBalance、Version、BaseModel 等字段
|
||||
- [x] 4.2 在 `card_wallet.go` 中定义 CardWalletTransaction 结构体,包含 CardWalletID、ResourceType、ResourceID、TransactionType、Amount、BalanceBefore、BalanceAfter 等字段
|
||||
- [x] 4.3 在 `card_wallet.go` 中定义 CardRechargeRecord 结构体,包含 UserID、CardWalletID、ResourceType、ResourceID、RechargeNo、Amount、PaymentMethod、Status 等字段
|
||||
- [x] 4.4 为所有 Model 实现 TableName() 方法,返回对应的表名(tb_card_wallet、tb_card_wallet_transaction、tb_card_recharge_record)
|
||||
- [x] 4.5 添加中文注释说明每个字段的用途和约束
|
||||
- [x] 4.6 编译验证 Model 定义正确,无语法错误
|
||||
|
||||
## 5. 卡钱包系统 - Store 层实现
|
||||
|
||||
- [x] 5.1 创建 `internal/store/postgres/card_wallet_store.go`,定义 CardWalletStore 结构体,包含 db 和 redis 字段
|
||||
- [x] 5.2 实现 `NewCardWalletStore` 构造函数
|
||||
- [x] 5.3 实现 `GetByResourceTypeAndID(ctx, resourceType, resourceID)` 方法,根据资源类型和 ID 查询钱包
|
||||
- [x] 5.4 实现 `GetByID(ctx, id)` 方法,根据钱包 ID 查询
|
||||
- [x] 5.5 实现 `DeductBalanceWithTx(ctx, tx, walletID, amount, version)` 方法,扣款(带事务,使用乐观锁)
|
||||
- [x] 5.6 实现 `AddBalanceWithTx(ctx, tx, walletID, amount)` 方法,增加余额(带事务)
|
||||
- [x] 5.7 创建 `internal/store/postgres/card_wallet_transaction_store.go`,定义 CardWalletTransactionStore 结构体
|
||||
- [x] 5.8 实现 `CreateWithTx(ctx, tx, transaction)` 方法,创建卡钱包交易记录(带事务)
|
||||
- [x] 5.9 实现 `ListByResourceID(ctx, resourceType, resourceID, offset, limit)` 方法,按资源查询交易记录(支持分页)
|
||||
- [x] 5.10 创建 `internal/store/postgres/card_recharge_store.go`,定义 CardRechargeStore 结构体
|
||||
- [x] 5.11 实现充值记录的 CRUD 方法(Create、GetByRechargeNo、UpdateStatus 等)
|
||||
- [x] 5.12 编译验证 Store 层代码正确,无语法错误
|
||||
|
||||
## 6. 常量定义更新
|
||||
|
||||
- [x] 6.1 更新 `pkg/constants/wallet.go`,按钱包类型重新组织常量定义
|
||||
- [x] 6.2 添加代理钱包专用常量(AgentRechargeOrderPrefix、AgentRechargeMinAmount、AgentRechargeMaxAmount)
|
||||
- [x] 6.3 添加卡钱包专用常量(CardWalletResourceTypeIotCard、CardWalletResourceTypeDevice、CardRechargeOrderPrefix、CardRechargeMinAmount、CardRechargeMaxAmount)
|
||||
- [x] 6.4 定义 Redis Key 生成函数:`RedisAgentWalletBalanceKey(shopID, walletType)`
|
||||
- [x] 6.5 定义 Redis Key 生成函数:`RedisAgentWalletLockKey(shopID, walletType)`
|
||||
- [x] 6.6 定义 Redis Key 生成函数:`RedisCardWalletBalanceKey(resourceType, resourceID)`
|
||||
- [x] 6.7 定义 Redis Key 生成函数:`RedisCardWalletLockKey(resourceType, resourceID)`
|
||||
- [x] 6.8 为所有常量和函数添加中文注释
|
||||
- [x] 6.9 编译验证常量定义正确,无语法错误
|
||||
|
||||
## 7. Bootstrap 层 - 注册新 Store
|
||||
|
||||
- [x] 7.1 在 `internal/bootstrap/stores.go` 中添加 AgentWalletStore 字段到 Stores 结构体
|
||||
- [x] 7.2 在 `internal/bootstrap/stores.go` 中添加 AgentWalletTransactionStore 字段到 Stores 结构体
|
||||
- [x] 7.3 在 `internal/bootstrap/stores.go` 中添加 AgentRechargeStore 字段到 Stores 结构体
|
||||
- [x] 7.4 在 `internal/bootstrap/stores.go` 中添加 CardWalletStore 字段到 Stores 结构体
|
||||
- [x] 7.5 在 `internal/bootstrap/stores.go` 中添加 CardWalletTransactionStore 字段到 Stores 结构体
|
||||
- [x] 7.6 在 `internal/bootstrap/stores.go` 中添加 CardRechargeStore 字段到 Stores 结构体
|
||||
- [x] 7.7 在 `NewStores()` 函数中初始化所有新 Store 实例(调用 NewXxxStore 构造函数)
|
||||
- [x] 7.8 编译验证 Bootstrap 层代码正确,无语法错误
|
||||
|
||||
## 8. Service 层重构 - 佣金相关服务
|
||||
|
||||
- [x] 8.1 更新 `internal/service/commission_calculation/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore`
|
||||
- [x] 8.2 更新 `internal/service/commission_calculation/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API
|
||||
- [x] 8.3 更新 `internal/service/commission_withdrawal/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore`
|
||||
- [x] 8.4 更新 `internal/service/commission_withdrawal/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API
|
||||
- [x] 8.5 更新 `internal/service/shop_commission/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore`
|
||||
- [x] 8.6 更新 `internal/service/shop_commission/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API
|
||||
- [x] 8.7 更新 `internal/service/my_commission/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore`
|
||||
- [x] 8.8 更新 `internal/service/my_commission/service.go` 中所有调用钱包的方法,使用新的 AgentWalletStore API
|
||||
- [x] 8.9 编译验证所有佣金相关服务重构正确,无语法错误
|
||||
|
||||
## 9. Service 层重构 - 订单服务
|
||||
|
||||
- [x] 9.1 更新 `internal/service/order/service.go`,将 `WalletStore` 依赖改为 `AgentWalletStore` 和 `CardWalletStore`
|
||||
- [x] 9.2 更新 `internal/service/order/service.go` 中的 `WalletPay()` 方法,使用新的 AgentWalletStore 和 CardWalletStore API(根据买家类型分别处理)
|
||||
- [x] 9.3 更新 `internal/service/order/service.go` 中的 `HandlePaymentCallback()` 方法,使用新的钱包 API
|
||||
- [x] 9.4 更新 `internal/service/order/service.go` 中所有其他调用钱包的方法,使用新的钱包 Store API
|
||||
- [x] 9.5 编译验证订单服务重构正确,无语法错误
|
||||
|
||||
## 10. Service 层重构 - 充值服务(注:实际采用原地重构方案,未拆分为两个独立服务)
|
||||
|
||||
- [x] 10.1 更新 `internal/service/recharge/service.go`,将依赖从 WalletStore 改为 CardWalletStore、CardWalletTransactionStore、CardRechargeStore
|
||||
- [x] 10.2 更新 Service 构造函数 New(),注入新的 CardWallet 相关 Store
|
||||
- [x] 10.3 更新 `Create()` 方法,使用 CardRechargeStore 创建充值订单,使用 CardWalletStore 查询钱包
|
||||
- [x] 10.4 更新 `HandlePaymentCallback()` 方法,使用 CardRechargeStore 和 CardWalletStore 处理支付回调
|
||||
- [x] 10.5 更新 `buildRechargeResponse()` 方法,适配 CardRechargeRecord 模型
|
||||
- [x] 10.6 更新 `List()` 方法,使用 CardRechargeStore.List() 查询充值记录
|
||||
- [x] 10.7 更新 `GetByID()` 方法,使用 CardRechargeStore.GetByID() 查询充值订单
|
||||
- [x] 10.8 更新所有佣金触发逻辑,使用 AgentWalletStore 处理佣金入账
|
||||
- [x] 10.9 在 CardRechargeStore 中添加 List()、UpdatePaymentInfo()、UpdateStatusWithOptimisticLock() 方法
|
||||
- [x] 10.10 更新 bootstrap/services.go,注入 CardRecharge、CardWallet、CardWalletTransaction Store
|
||||
- [x] 10.11 编译验证充值服务重构正确,无语法错误
|
||||
|
||||
## 11. Bootstrap 层 - 更新 Service 依赖注入
|
||||
|
||||
- [x] 11.1 在 `internal/bootstrap/services.go` 中更新 CommissionCalculationService,注入 AgentWalletStore 和 AgentWalletTransactionStore
|
||||
- [x] 11.2 在 `internal/bootstrap/services.go` 中更新 CommissionWithdrawalService,注入 AgentWalletStore 和 AgentWalletTransactionStore
|
||||
- [x] 11.3 在 `internal/bootstrap/services.go` 中更新 ShopCommissionService,注入 AgentWalletStore
|
||||
- [x] 11.4 在 `internal/bootstrap/services.go` 中更新 MyCommissionService,注入 AgentWalletStore 和 AgentWalletTransactionStore
|
||||
- [x] 11.5 在 `internal/bootstrap/services.go` 中更新 OrderService,注入 AgentWalletStore 和 CardWalletStore
|
||||
- [x] 11.6 在 `internal/bootstrap/services.go` 中更新 RechargeService,注入 CardRechargeStore、CardWalletStore、CardWalletTransactionStore
|
||||
- [x] 11.7 在 `internal/bootstrap/worker_services.go` 中更新 CommissionCalculationService,注入 AgentWalletStore 和 AgentWalletTransactionStore
|
||||
- [x] 11.8 在 `pkg/queue/types.go` 中更新 WorkerStores,添加 AgentWallet 和 AgentWalletTransaction 字段
|
||||
- [x] 11.9 编译验证 Service 依赖注入更新正确,无语法错误
|
||||
|
||||
## 12. 清理旧代码 - 删除旧 Model 和 Store
|
||||
|
||||
- [x] 12.1 删除 `internal/model/wallet.go` 文件(包含 Wallet、WalletTransaction、RechargeRecord、WalletMetadata)
|
||||
- [x] 12.2 删除 `internal/store/postgres/wallet_store.go` 文件
|
||||
- [x] 12.3 删除 `internal/store/postgres/wallet_transaction_store.go` 文件
|
||||
- [x] 12.4 从 `internal/bootstrap/stores.go` 中移除 WalletStore 和 WalletTransactionStore 字段
|
||||
- [x] 12.5 从 `internal/bootstrap/stores.go` 的 `NewStores()` 函数中移除 WalletStore 和 WalletTransactionStore 初始化
|
||||
- [x] 12.6 编译检查,确保无旧代码引用残留
|
||||
|
||||
## 13. 数据库迁移 - 删除旧表
|
||||
|
||||
- [x] 13.1 创建删除旧表的迁移文件(DROP TABLE tb_wallet、tb_wallet_transaction、tb_recharge_record)
|
||||
- [x] 13.2 执行数据库迁移,验证旧表删除成功
|
||||
- [x] 13.3 检查数据库中只剩下新的 6 张表
|
||||
|
||||
## 14. 手动测试 - 代理钱包核心流程
|
||||
|
||||
- [ ] 14.1 测试代理钱包创建:为店铺 ID 10 创建主钱包和分佣钱包
|
||||
- [ ] 14.2 测试代理钱包充值:主钱包充值 100000 分(1000 元),验证余额正确
|
||||
- [ ] 14.3 测试代理钱包扣款:主钱包扣款 30000 分(300 元),验证余额正确,创建交易记录
|
||||
- [ ] 14.4 测试余额不足场景:尝试扣款超过可用余额,验证返回"余额不足"错误
|
||||
- [ ] 14.5 测试冻结余额:分佣钱包冻结 50000 分用于提现,验证 frozen_balance 增加,可用余额减少
|
||||
- [ ] 14.6 测试解冻余额:取消提现,验证冻结余额减少,可用余额恢复
|
||||
- [ ] 14.7 测试并发扣款:模拟两个并发请求同时扣款,验证乐观锁生效(一个成功,一个失败重试)
|
||||
- [ ] 14.8 测试交易记录查询:按店铺 ID 查询交易历史,验证分页和排序正确
|
||||
|
||||
## 15. 手动测试 - 卡钱包核心流程
|
||||
|
||||
- [ ] 15.1 测试卡钱包创建:为物联网卡(resource_type=iot_card, resource_id=100)创建钱包
|
||||
- [ ] 15.2 测试卡钱包充值:卡钱包充值 10000 分(100 元),验证余额正确
|
||||
- [ ] 15.3 测试卡钱包扣款:购买套餐扣款 3000 分(30 元),验证余额正确,创建交易记录
|
||||
- [ ] 15.4 测试订单退款:退款 3000 分,验证余额恢复,创建退款交易记录
|
||||
- [ ] 15.5 测试余额不足场景:尝试扣款超过可用余额,验证返回"余额不足"错误
|
||||
- [ ] 15.6 测试设备钱包:为设备(resource_type=device, resource_id=200)创建钱包,验证设备的多张卡共享钱包
|
||||
- [ ] 15.7 测试并发扣款:模拟两个并发请求同时扣款,验证乐观锁生效
|
||||
- [ ] 15.8 测试交易记录查询:按资源 ID 查询交易历史,验证分页和排序正确
|
||||
|
||||
## 16. 手动测试 - 充值流程
|
||||
|
||||
- [ ] 16.1 测试代理充值最小金额限制:尝试充值 50 元,验证返回"充值金额不能低于 100 元"错误
|
||||
- [ ] 16.2 测试代理充值订单创建:创建充值订单,验证生成唯一的 recharge_no(ARCH 前缀),状态为"待支付"
|
||||
- [ ] 16.3 测试代理充值支付完成:模拟支付回调,验证状态从"待支付"变为"已支付"
|
||||
- [ ] 16.4 测试代理充值到账:处理充值到账,验证钱包余额增加,状态变为"已完成",创建交易记录
|
||||
- [ ] 16.5 测试卡充值最小金额限制:尝试充值 0.5 元,验证返回"充值金额不能低于 1 元"错误
|
||||
- [ ] 16.6 测试卡充值订单创建:创建充值订单,验证生成唯一的 recharge_no(CRCH 前缀),状态为"待支付"
|
||||
- [ ] 16.7 测试卡充值支付完成:模拟支付回调,验证状态从"待支付"变为"已支付"
|
||||
- [ ] 16.8 测试卡充值到账:处理充值到账,验证钱包余额增加,状态变为"已完成",创建交易记录
|
||||
|
||||
## 17. 数据一致性验证
|
||||
|
||||
- [ ] 17.1 验证代理钱包交易记录的 balance_before 和 balance_after 准确性:查询所有交易记录,计算余额变动,与实际余额对比
|
||||
- [ ] 17.2 验证卡钱包交易记录的 balance_before 和 balance_after 准确性:查询所有交易记录,计算余额变动,与实际余额对比
|
||||
- [ ] 17.3 验证乐观锁 version 字段在并发场景下的有效性:模拟高并发扣款,验证 version 正确递增,无丢失更新
|
||||
- [ ] 17.4 验证 Redis 缓存一致性:余额变动后,检查缓存是否被正确删除,下次查询是否重新加载
|
||||
|
||||
## 18. 最终验证和清理
|
||||
|
||||
- [x] 18.1 运行 `go build ./...` 编译整个项目,确保无编译错误
|
||||
- [x] 18.2 运行 `go mod tidy` 清理未使用的依赖
|
||||
- [x] 18.3 检查所有文件的中文注释是否完整
|
||||
- [x] 18.4 检查所有常量是否定义在 `pkg/constants/` 中,无硬编码
|
||||
- [x] 18.5 检查所有错误返回是否使用 `errors.New()` 或 `errors.Wrap()`,无 `fmt.Errorf()`
|
||||
- [x] 18.6 使用 `gofmt -w .` 格式化所有代码
|
||||
- [x] 18.7 检查 git status,确认所有变更符合预期
|
||||
- [x] 18.8 准备提交代码,编写 Git Commit 信息(中文)
|
||||
|
||||
---
|
||||
|
||||
**注意事项**:
|
||||
|
||||
1. **任务顺序不可颠倒**:必须先完成数据库迁移和 Model 层,再实现 Store 层,最后重构 Service 层
|
||||
2. **逐项标记完成**:每完成一个任务,将 `[ ]` 改为 `[x]`
|
||||
3. **遇到问题时停止**:如果某个任务无法完成或发现设计问题,立即停止并与团队讨论
|
||||
4. **编译验证**:每个阶段完成后必须编译验证,确保无语法错误
|
||||
5. **手动测试不可跳过**:所有核心流程必须手动测试验证,确保功能正确
|
||||
318
openspec/specs/agent-wallet/spec.md
Normal file
318
openspec/specs/agent-wallet/spec.md
Normal file
@@ -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`,第一个请求获得锁,第二个请求等待或失败
|
||||
|
||||
---
|
||||
333
openspec/specs/card-wallet/spec.md
Normal file
333
openspec/specs/card-wallet/spec.md
Normal file
@@ -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`,第一个请求获得锁,第二个请求等待或失败
|
||||
|
||||
---
|
||||
@@ -1,254 +1,88 @@
|
||||
# wallet Specification
|
||||
# wallet Specification (DEPRECATED)
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose after archive.
|
||||
## Requirements
|
||||
**⚠️ 此规范已废弃**
|
||||
|
||||
钱包系统已重构,废弃统一钱包设计,拆分为 `agent-wallet`(代理钱包)和 `card-wallet`(卡钱包)两个完全独立的系统,实现数据层和代码层的完全隔离。
|
||||
|
||||
**请参阅新规范:**
|
||||
- 代理钱包系统:[agent-wallet/spec.md](../agent-wallet/spec.md)
|
||||
- 卡钱包系统:[card-wallet/spec.md](../card-wallet/spec.md)
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 钱包实体定义
|
||||
|
||||
系统 SHALL 定义钱包(Wallet)实体,统一管理用户钱包和代理钱包,支持余额管理、充值、扣款等操作。
|
||||
**⚠️ 已废弃** - 废弃统一钱包设计,拆分为代理钱包(AgentWallet)和卡钱包(CardWallet)两个独立实体,使用独立的数据表。
|
||||
|
||||
**核心概念**:
|
||||
- **用户钱包**:普通用户和企业用户的钱包,用于购买套餐
|
||||
- **代理钱包**:代理商的钱包,支持预充值,可用成本价购买套餐
|
||||
|
||||
**实体字段**:
|
||||
- `id`:钱包 ID(主键,BIGINT)
|
||||
- `user_id`:用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `wallet_type`:钱包类型(VARCHAR(20),枚举值:"user"-用户钱包 | "agent"-代理钱包)
|
||||
- `balance`:余额(BIGINT,单位:分,默认 0)
|
||||
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,用于订单待支付、提现申请中等场景)
|
||||
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭)
|
||||
- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `updater`:更新人 ID(BIGINT)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**唯一约束**:`(user_id, wallet_type, currency)` 在 `deleted_at IS NULL` 条件下唯一
|
||||
|
||||
**可用余额计算**:可用余额 = balance - frozen_balance
|
||||
|
||||
#### Scenario: 创建用户钱包
|
||||
|
||||
- **WHEN** 用户(ID 为 2001)首次充值
|
||||
- **THEN** 系统创建钱包记录,`user_id` 为 2001,`wallet_type` 为 "user",`balance` 为 0,`status` 为 1(正常)
|
||||
|
||||
#### Scenario: 创建代理钱包
|
||||
|
||||
- **WHEN** 代理商(ID 为 123)首次充值
|
||||
- **THEN** 系统创建钱包记录,`user_id` 为 123,`wallet_type` 为 "agent",`balance` 为 0,`status` 为 1(正常)
|
||||
|
||||
#### Scenario: 计算可用余额
|
||||
|
||||
- **WHEN** 用户钱包余额为 10000 分(100 元),冻结余额为 3000 分(30 元)
|
||||
- **THEN** 系统计算可用余额为 7000 分(70 元)
|
||||
**迁移指南**:
|
||||
- 代理钱包(shop 类型)→ `tb_agent_wallet` 表,参见 [agent-wallet spec](../agent-wallet/spec.md)
|
||||
- 卡钱包(iot_card 和 device 类型)→ `tb_card_wallet` 表,参见 [card-wallet spec](../card-wallet/spec.md)
|
||||
- 代码层使用新的 Model:`model.AgentWallet` 和 `model.CardWallet`
|
||||
- 代码层使用新的 Store:`AgentWalletStore` 和 `CardWalletStore`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包明细记录
|
||||
|
||||
系统 SHALL 记录所有钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。
|
||||
**⚠️ 已废弃** - 废弃统一交易记录表,拆分为代理钱包交易记录(tb_agent_wallet_transaction)和卡钱包交易记录(tb_card_wallet_transaction)两个独立表。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:明细 ID(主键,BIGINT)
|
||||
- `wallet_id`:钱包 ID(BIGINT,关联 tb_wallet.id)
|
||||
- `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-处理中)
|
||||
- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup")
|
||||
- `reference_id`:关联业务 ID(BIGINT)
|
||||
- `remark`:备注(TEXT)
|
||||
- `metadata`:扩展信息(JSONB,如手续费、支付方式等)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
#### Scenario: 充值创建明细记录
|
||||
|
||||
- **WHEN** 用户(ID 为 2001)充值 10000 分(100 元)
|
||||
- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "recharge",`amount` 为 10000,`balance_before` 为 0,`balance_after` 为 10000,`status` 为 1(成功)
|
||||
|
||||
#### Scenario: 购买套餐扣款创建明细记录
|
||||
|
||||
- **WHEN** 用户(ID 为 2001)使用钱包支付购买套餐,金额 3000 分(30 元)
|
||||
- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "deduct",`amount` 为 -3000,`balance_before` 为 10000,`balance_after` 为 7000,`reference_type` 为 "order",`reference_id` 为订单 ID
|
||||
|
||||
#### Scenario: 分佣发放创建明细记录
|
||||
|
||||
- **WHEN** 代理(ID 为 123)的分佣 5000 分(50 元)审批通过并发放
|
||||
- **THEN** 系统创建钱包明细记录,`transaction_type` 为 "commission",`amount` 为 5000,`balance_before` 为 20000,`balance_after` 为 25000,`reference_type` 为 "commission",`reference_id` 为分佣记录 ID
|
||||
**迁移指南**:
|
||||
- 代理钱包交易记录 → `tb_agent_wallet_transaction` 表,参见 [agent-wallet spec](../agent-wallet/spec.md)
|
||||
- 卡钱包交易记录 → `tb_card_wallet_transaction` 表,参见 [card-wallet spec](../card-wallet/spec.md)
|
||||
- 代码层使用新的 Model:`model.AgentWalletTransaction` 和 `model.CardWalletTransaction`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 充值记录管理
|
||||
|
||||
系统 SHALL 记录所有充值操作,包括充值订单号、金额、支付方式、支付状态等信息。
|
||||
**⚠️ 已废弃** - 废弃统一充值记录表,拆分为代理充值记录(tb_agent_recharge_record)和卡充值记录(tb_card_recharge_record)两个独立表。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:充值记录 ID(主键,BIGINT)
|
||||
- `user_id`:用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `wallet_id`:钱包 ID(BIGINT,关联 tb_wallet.id)
|
||||
- `recharge_no`:充值订单号(VARCHAR(50),唯一)
|
||||
- `amount`:充值金额(BIGINT,单位:分)
|
||||
- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下)
|
||||
- `payment_channel`:支付渠道(VARCHAR(50))
|
||||
- `payment_transaction_id`:第三方支付交易号(VARCHAR(100))
|
||||
- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
|
||||
- `paid_at`:支付时间(TIMESTAMP,可空)
|
||||
- `completed_at`:完成时间(TIMESTAMP,可空)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `updater`:更新人 ID(BIGINT)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
#### Scenario: 创建充值订单
|
||||
|
||||
- **WHEN** 用户(ID 为 2001)发起充值 10000 分(100 元),选择支付宝支付
|
||||
- **THEN** 系统创建充值记录,生成唯一的 `recharge_no`,`amount` 为 10000,`payment_method` 为 "alipay",`status` 为 1(待支付)
|
||||
|
||||
#### Scenario: 充值支付完成
|
||||
|
||||
- **WHEN** 用户完成支付宝支付
|
||||
- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id`
|
||||
|
||||
#### Scenario: 充值到账
|
||||
|
||||
- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账
|
||||
- **THEN** 系统将钱包余额增加 10000 分,创建钱包明细记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间
|
||||
**迁移指南**:
|
||||
- 代理充值记录 → `tb_agent_recharge_record` 表,参见 [agent-wallet spec](../agent-wallet/spec.md)
|
||||
- 卡充值记录 → `tb_card_recharge_record` 表,参见 [card-wallet spec](../card-wallet/spec.md)
|
||||
- 代码层使用新的 Model:`model.AgentRechargeRecord` 和 `model.CardRechargeRecord`
|
||||
- 充值服务拆分为独立的代理充值和卡充值逻辑
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包余额操作
|
||||
|
||||
系统 SHALL 支持钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。
|
||||
**⚠️ 已废弃** - 余额操作逻辑拆分到代理钱包和卡钱包两个独立系统,使用各自的 Store 实现。
|
||||
|
||||
**操作类型**:
|
||||
- **充值**:增加钱包余额
|
||||
- **扣款**:减少钱包余额(如购买套餐)
|
||||
- **退款**:增加钱包余额(如订单退款)
|
||||
- **冻结**:将部分余额转为冻结状态(如订单待支付)
|
||||
- **解冻**:将冻结余额转回可用余额(如订单取消)
|
||||
|
||||
**并发控制**:
|
||||
- 使用 `version` 字段实现乐观锁
|
||||
- 每次更新余额时,检查 `version` 是否匹配
|
||||
- 如果 `version` 不匹配,说明有并发更新,操作失败并重试
|
||||
|
||||
#### Scenario: 钱包充值
|
||||
|
||||
- **WHEN** 用户钱包当前余额为 10000 分,充值 5000 分
|
||||
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建钱包明细记录
|
||||
|
||||
#### Scenario: 钱包扣款
|
||||
|
||||
- **WHEN** 用户钱包当前余额为 15000 分,购买套餐扣款 3000 分
|
||||
- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建钱包明细记录
|
||||
|
||||
#### Scenario: 余额不足扣款失败
|
||||
|
||||
- **WHEN** 用户钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
|
||||
- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足"
|
||||
|
||||
#### Scenario: 并发扣款乐观锁生效
|
||||
|
||||
- **WHEN** 用户钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分
|
||||
- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)后重试
|
||||
|
||||
#### Scenario: 冻结余额
|
||||
|
||||
- **WHEN** 用户创建订单 10001,订单金额 3000 分,选择钱包支付
|
||||
- **THEN** 系统将钱包的 `frozen_balance` 增加 3000 分,可用余额减少 3000 分
|
||||
|
||||
#### Scenario: 解冻余额
|
||||
|
||||
- **WHEN** 用户取消订单 10001,订单金额 3000 分
|
||||
- **THEN** 系统将钱包的 `frozen_balance` 减少 3000 分,可用余额增加 3000 分
|
||||
**迁移指南**:
|
||||
- 代理钱包余额操作 → 使用 `AgentWalletStore`,参见 [agent-wallet spec](../agent-wallet/spec.md)
|
||||
- 卡钱包余额操作 → 使用 `CardWalletStore`,参见 [card-wallet spec](../card-wallet/spec.md)
|
||||
- 并发控制(乐观锁)机制保持不变,继续使用 `version` 字段
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包数据校验
|
||||
|
||||
系统 SHALL 对钱包数据进行校验,确保数据完整性和一致性。
|
||||
**⚠️ 已废弃** - 数据校验规则拆分到代理钱包和卡钱包两个独立系统,针对各自的字段设计优化。
|
||||
|
||||
**校验规则变更**:
|
||||
- ~~`user_id`~~:~~必填,≥ 1~~(**已删除**)
|
||||
- `resource_type`:必填,枚举值 "iot_card" | "device" | "shop"(**新增**)
|
||||
- `resource_id`:必填,≥ 1,必须是有效的资源 ID(**新增**)
|
||||
- `wallet_type`:必填,枚举值 "main" | "commission"
|
||||
- `balance`:必填,≥ 0
|
||||
- `frozen_balance`:必填,≥ 0,≤ balance
|
||||
- `currency`:必填,长度 1-10 字符
|
||||
- `status`:必填,枚举值 1-3
|
||||
- `version`:必填,≥ 0
|
||||
|
||||
#### Scenario: 创建钱包时 resource_type 无效
|
||||
|
||||
- **WHEN** 创建钱包,`resource_type` 为 "invalid"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card、device 或 shop"
|
||||
|
||||
#### Scenario: 创建钱包时 resource_id 无效
|
||||
|
||||
- **WHEN** 创建钱包,`resource_type` 为 "iot_card",`resource_id` 为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
|
||||
|
||||
#### Scenario: 冻结余额超过总余额
|
||||
|
||||
- **WHEN** 钱包余额为 10000 分,尝试冻结 15000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
||||
|
||||
### Requirement: 钱包归属资源规则
|
||||
|
||||
系统 SHALL 根据资源类型管理钱包归属,支持个人客户卡/设备转手和代理商店铺级别管理。
|
||||
|
||||
**归属规则**:
|
||||
|
||||
| 资源类型 | ResourceType | 适用场景 | 说明 |
|
||||
|---------|-------------|---------|------|
|
||||
| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 |
|
||||
| 设备 | device | 个人客户购买设备(含1-4张卡) | 钱包归属设备,设备的多张卡共享钱包 |
|
||||
| 店铺 | shop | 代理商预存款 | 钱包归属店铺,店铺的多个员工账号共享钱包 |
|
||||
|
||||
**资源转手规则**:
|
||||
- 物联网卡转手:新用户登录后可以看到卡的钱包余额
|
||||
- 设备转手:新用户登录后可以看到设备的钱包余额(包含绑定的所有卡)
|
||||
- 店铺钱包:不支持转手,归属店铺不变
|
||||
|
||||
#### 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 张卡共享该钱包
|
||||
|
||||
#### 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** 代理商(店铺 ID 为 10)充值 50000 分
|
||||
- **THEN** 系统创建或更新钱包记录,`resource_type` 为 "shop",`resource_id` 为 10,`balance` 增加 50000 分
|
||||
|
||||
#### Scenario: 代理商店铺的多个员工账号共享钱包
|
||||
|
||||
- **WHEN** 代理商店铺(店铺 ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺钱包余额为 50000 分
|
||||
- **THEN** 3 个员工账号登录后查询店铺钱包,余额都是 50000 分,可以共享使用
|
||||
**迁移指南**:
|
||||
- 代理钱包数据校验:使用 `shop_id` + `wallet_type`,参见 [agent-wallet spec](../agent-wallet/spec.md)
|
||||
- 卡钱包数据校验:使用 `resource_type` + `resource_id`,参见 [card-wallet spec](../card-wallet/spec.md)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包归属资源规则
|
||||
|
||||
**⚠️ 已废弃** - 归属规则拆分到代理钱包和卡钱包两个独立系统,业务语义更清晰。
|
||||
|
||||
**迁移指南**:
|
||||
- 代理钱包归属店铺(shop_id),不支持转手,参见 [agent-wallet spec](../agent-wallet/spec.md)
|
||||
- 卡钱包归属资源(iot_card / device),支持转手,参见 [card-wallet spec](../card-wallet/spec.md)
|
||||
|
||||
---
|
||||
|
||||
## 变更历史
|
||||
|
||||
- **2026-02-25**: 钱包系统重构,废弃统一钱包设计,拆分为 agent-wallet 和 card-wallet 两个独立系统
|
||||
- 旧的 3 张表(tb_wallet、tb_wallet_transaction、tb_recharge_record)已删除
|
||||
- 新的 6 张表已创建并投入使用
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user