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

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

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

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

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

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

16 KiB
Raw Blame History

钱包系统分离 - 技术设计

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. 不修改对外 APIHandler 层接口保持不变,仅内部调用改为新的 Store

Decisions

决策 1交易记录表和充值记录表也完全分离

决策:不仅分离钱包主表,交易记录表和充值记录表也按钱包类型分离

理由

  • 数据量级差异大:卡钱包交易记录可能是代理钱包的 100 倍以上(每次套餐扣费都有记录)
  • 查询隔离:代理的对账报表不应该被卡交易的查询拖慢
  • 索引优化独立:代理钱包的交易查询模式(按店铺、按时间范围)与卡钱包(按资源、按订单)完全不同

替代方案

  • 保留统一交易记录表,通过 wallet_table_type 字段区分:隔离不彻底,查询性能受限

代价

  • 如果未来需要跨钱包类型的全局交易统计需要跨表查询UNION或创建视图

决策 2代理钱包简化为 shop_id 主键设计

当前设计(统一表):

tb_wallet (
    resource_type = 'shop',
    resource_id = shop_id,
    wallet_type = 'main' | 'commission'
)

新设计(简化):

tb_agent_wallet (
    shop_id,
    wallet_type = 'main' | 'commission'
)

理由

  • 代理钱包只归属店铺,不需要 resource_type 字段
  • 简化查询逻辑,直接用 shop_id 查询
  • 类型更明确,编译期防止误用

索引设计

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 设计

设计

tb_card_wallet (
    resource_type = 'iot_card' | 'device',
    resource_id,
    balance, frozen_balance, version
)

理由

  • 卡钱包需要支持两种资源类型(物联网卡、设备)
  • 保留灵活性,未来可能增加其他资源类型(如企业设备)
  • 与现有业务逻辑保持一致

索引设计

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 层类型完全独立

设计

// 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 层完全独立

设计

// 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充值服务拆分

当前

internal/service/recharge/
└─ service.go  // 处理所有类型钱包的充值

新设计

internal/service/agent_recharge/
└─ service.go  // 只处理代理钱包充值

internal/service/card_recharge/
└─ service.go  // 只处理卡钱包充值

理由

  • 代理充值和卡充值的业务流程差异大:
    • 代理充值金额限制更高100元起、支持线下转账、需要审核
    • 卡充值金额限制更低1元起、仅支持在线支付、自动到账
  • 拆分后代码更清晰,避免 if-else 分支判断
  • 独立部署和监控(如果未来微服务化)

Handler 层不变

  • Handler 层根据用户类型调用不同的 Service
  • 对外 API 接口保持不变

决策 7Redis Key 按钱包类型隔离

当前

wallet:balance:{wallet_id}
wallet:lock:{wallet_id}

新设计

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

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索引策略独立优化

代理钱包索引(低频、大金额):

-- 主查询:按店铺查询钱包
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)

卡钱包索引(高频、小金额):

-- 主查询:按资源查询钱包
idx_card_wallet_resource (resource_type, resource_id, deleted_at)

-- 次要查询:按状态过滤
idx_card_wallet_status (status)

-- 多租户过滤
idx_card_wallet_shop_tag (shop_id_tag)

交易记录索引

代理钱包交易:

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)     -- 按交易类型统计

卡钱包交易:

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 字段

设计

// 扣款时检查 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. 创建新 Modelagent_wallet.gocard_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. 删除旧 Modelinternal/model/wallet.go
  2. 删除旧 Storewallet_store.gowallet_transaction_store.go
  3. 编译检查,确保无引用残留
  4. 手动测试核心流程
  5. 数据一致性验证

总计6 天

回滚策略

如果重构失败(仅适用于代码未合并到主分支前):

  1. 恢复旧 Model 和 Store 代码
  2. 恢复旧的数据库表(从备份或重新运行旧迁移)
  3. 恢复旧的依赖注入配置

预防措施

  • 在独立分支上进行重构
  • 每个阶段完成后提交代码
  • 保留旧代码直到新代码验证通过

Open Questions

Q1是否需要创建数据库视图方便全局统计

场景:未来可能需要全局交易统计(跨代理钱包和卡钱包)

选项

  • 选项 A:创建 VIEW v_all_wallet_transactionsUNION 两张交易表)
  • 选项 B:不创建视图,需要时在应用层 UNION 查询
  • 选项 C:创建定时任务,汇总到独立的统计表

建议等到有明确需求时再决定当前不创建YAGNI 原则)


Q2监控指标如何实现

场景:提案中建议新增监控指标(agent_wallet_error_ratecard_wallet_error_rate

问题

  • 是否在本次重构中实现监控埋点?
  • 还是只预留接口,后续专门做监控系统?

建议:本次重构不包含监控实现,只确保代码层面可以区分钱包类型(便于未来埋点)


Q3是否需要为 BaseModel 字段添加到新表?

当前:旧表包含 BaseModelshop_id_tagenterprise_id_tag 等多租户字段)

问题:新表是否需要保留这些字段?

建议:保留,因为系统使用 GORM Callback 自动过滤多租户数据,这些字段是必需的