# 钱包系统分离 - 技术设计

## 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 自动过滤多租户数据，这些字段是必需的
