Files
huang 18daeae65a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m17s
feat: 钱包系统分离 - 代理钱包与卡钱包完全隔离
## 变更概述
将统一钱包系统拆分为代理钱包和卡钱包两个独立系统,实现数据表和代码层面的完全隔离。

## 数据库变更
- 新增 6 张表:tb_agent_wallet、tb_agent_wallet_transaction、tb_agent_recharge_record、tb_card_wallet、tb_card_wallet_transaction、tb_card_recharge_record
- 删除 3 张旧表:tb_wallet、tb_wallet_transaction、tb_recharge_record
- 代理钱包:按 (shop_id, wallet_type) 唯一标识,支持主钱包和分佣钱包
- 卡钱包:按 (resource_type, resource_id) 唯一标识,支持物联网卡和设备

## 代码变更
- Model 层:新增 AgentWallet、AgentWalletTransaction、AgentRechargeRecord、CardWallet、CardWalletTransaction、CardRechargeRecord 模型
- Store 层:新增 6 个独立 Store,支持事务、乐观锁、Redis 缓存
- Service 层:重构 commission_calculation、commission_withdrawal、order、recharge 等 8 个服务
- Bootstrap 层:更新 Store 和 Service 依赖注入
- 常量层:按钱包类型重新组织常量和 Redis Key 生成函数

## 技术特性
- 乐观锁:使用 version 字段防止并发冲突
- 多租户:支持 shop_id_tag 和 enterprise_id_tag 过滤
- 事务管理:所有余额变动使用事务保证 ACID
- 缓存策略:Cache-Aside 模式,余额变动后删除缓存

## 业务影响
- 代理钱包和卡钱包业务完全隔离,互不影响
- 为独立监控、优化、扩展打下基础
- 提升代理钱包的稳定性和独立性

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 09:51:00 +08:00

541 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 钱包系统分离 - 技术设计
## 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) -- 多租户过滤
```
---
### 决策 4Model 层类型完全独立
**设计**
```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` 基类 + 接口抽象:过度设计,增加复杂度
---
### 决策 5Store 层完全独立
**设计**
```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 接口保持不变
---
### 决策 7Redis 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`
**阶段 2Model 和 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暂不删除
**阶段 3Service 层重构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 自动过滤多租户数据,这些字段是必需的