# 钱包系统分离 - 技术设计 ## 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 自动过滤多租户数据,这些字段是必需的