## Context ### 背景 当前套餐与佣金系统在快速迭代中积累了技术债务,主要问题: 1. **套餐价格字段混乱**:`Price`、`SuggestedCostPrice`、`SuggestedRetailPrice` 三个字段语义不清,不同场景使用不一致 2. **流量字段设计缺陷**:`DataType` 暗示真/虚流量二选一,但业务需求是共存机制 3. **分配层次过多**:存在 `ShopSeriesAllocation`(系列分配)和 `ShopPackageAllocation`(套餐分配)两层,但业务模型只需要一层 4. **差价佣金计算复杂**:使用 `BaseCommissionMode/Value` 动态计算,但业务模型是简单的成本价差值 5. **一次性佣金配置位置错误**:配置在系列分配表中,应该只在套餐分配表中存储金额 ### 业务模型(Source of Truth) 详见 [`docs/commission-package-model.md`](../../../docs/commission-package-model.md) 核心要点: - **只有一层分配**:`ShopPackageAllocation`(套餐分配) - **差价佣金 = 下级成本价 - 自己成本价**(固定差值,无动态计算) - **一次性佣金规则在系列定义,金额在套餐分配中设置** ### 现有架构(需要重构) ``` tb_package_series # 套餐系列(含一次性佣金规则) │ ├── tb_package # 套餐 │ └── tb_shop_series_allocation # ❌ 系列分配(需要删除) │ └── tb_shop_package_allocation # 套餐分配 ``` ### 目标架构 ``` tb_package_series # 套餐系列(含一次性佣金规则配置) │ └── tb_package # 套餐 │ └── tb_shop_package_allocation # 套餐分配(唯一的分配表) ├── shop_id # 被分配的店铺 ├── package_id # 套餐ID ├── series_id # 系列ID(新增,用于关联规则) ├── allocator_shop_id # 分配者店铺ID(新增) ├── cost_price # 该代理的成本价 ├── one_time_commission_amount # 该代理能拿到的一次性佣金 └── status ``` ### 约束条件 - 不使用外键约束和 GORM 关联关系 - 必须支持向后兼容的数据迁移 - 分层架构:Handler → Service → Store → Model - 异步任务使用 Asynq --- ## Goals / Non-Goals ### Goals 1. **简化套餐模型**:只保留 `cost_price` + `suggested_retail_price`,语义清晰 2. **支持流量共存**:真流量必填 + 虚流量可选开关,停机判断逻辑统一 3. **删除 ShopSeriesAllocation**:移除多余的分配层次 4. **简化差价佣金计算**:直接使用成本价差值,删除动态计算逻辑 5. **统一分配模型**:一次性佣金金额只存储在 `ShopPackageAllocation` 中 6. **代理视角隔离**:不同代理看到自己的成本价和能拿到的一次性佣金 ### Non-Goals - 不重构订单支付流程(仅适配新的佣金计算) - 不修改钱包充值逻辑(仅增加累计追踪) - 不修改梯度佣金的统计存储结构(仅增加统计范围开关) - 不处理历史订单的佣金重算(迁移只处理配置数据) --- ## Decisions ### D1: 删除 ShopSeriesAllocation 及相关表 **决策**:完全删除以下表和相关代码 | 表名 | 说明 | 删除原因 | |------|------|----------| | `tb_shop_series_allocation` | 系列分配表 | 多余的分配层次 | | `tb_shop_series_allocation_config` | 系列分配配置版本表 | 依赖于 series_allocation | | `tb_shop_series_one_time_commission_tier` | 系列分配梯度配置表 | 梯度配置移到系列规则中 | **代码删除清单**: - `internal/model/shop_series_allocation.go` - `internal/model/shop_series_allocation_config.go` - `internal/model/dto/shop_series_allocation.go` - `internal/store/postgres/shop_series_allocation_store.go` - `internal/store/postgres/shop_series_allocation_config_store.go` - `internal/service/shop_series_allocation/service.go` - `internal/handler/admin/shop_series_allocation.go` - `internal/routes/shop_series_allocation.go` - `pkg/utils/commission.go`(CalculateCostPrice 函数) **理由**: - 业务模型只需要一层分配(套餐分配) - 系列分配增加了不必要的复杂度 - 所有需要的信息都可以在套餐分配中存储 ### D2: 修改 ShopPackageAllocation 模型 **决策**:扩展 `ShopPackageAllocation` 以承担原 `ShopSeriesAllocation` 的职责 ```go // Before type ShopPackageAllocation struct { ID uint AllocationID uint // ❌ 外键到 ShopSeriesAllocation(删除) PackageID uint CostPrice int64 Status int } // After type ShopPackageAllocation struct { ID uint ShopID uint // 被分配的店铺(新增) PackageID uint // 套餐ID SeriesID uint // 系列ID(新增,用于关联一次性佣金规则) AllocatorShopID uint // 分配者店铺ID(新增,0表示平台) CostPrice int64 // 该代理的成本价 OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金金额(新增) Status int Creator uint Updater uint } ``` **理由**: - `ShopID` 和 `AllocatorShopID` 原本存储在 `ShopSeriesAllocation` 中 - `SeriesID` 用于查询一次性佣金规则(从 `PackageSeries.OneTimeCommissionConfig` 获取) - `OneTimeCommissionAmount` 存储分配时设置的金额 ### D3: 差价佣金计算简化 **决策**:删除动态计算逻辑,使用固定成本价差值 ```go // Before: 动态计算(删除) // pkg/utils/commission.go func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 { switch allocation.BaseCommissionMode { case "fixed": return orderAmount - allocation.BaseCommissionValue case "percent": return orderAmount * (100 - allocation.BaseCommissionValue) / 100 } return orderAmount } // After: 简单差值计算 // 差价佣金 = 下级成本价 - 自己成本价 func CalculateDifferenceCommission(myCostPrice, subCostPrice int64) int64 { return subCostPrice - myCostPrice } ``` **示例**: ``` 平台成本价: 100 代理A成本价: 120(分配时设置) 代理A1成本价: 130(A分配给A1时设置) 当A1销售时: - A1利润 = 售价 - 130 - A差价佣金 = 130 - 120 = 10(固定) - 平台收入 = 120 ``` **理由**: - 业务模型明确定义差价佣金 = 下级成本价 - 自己成本价 - 无需 `BaseCommissionMode/Value` 的动态计算 - 简化代码,减少出错可能 ### D4: 一次性佣金金额存储位置 **决策**:一次性佣金金额只存储在 `ShopPackageAllocation.OneTimeCommissionAmount` ```go // 套餐系列定义规则(不变) type PackageSeries struct { // ... 基础字段 EnableOneTimeCommission bool // 是否启用 OneTimeCommissionConfig string // JSON:触发条件、阈值、金额/梯度等 } // 套餐分配记录每个代理能拿到的金额 type ShopPackageAllocation struct { // ... 其他字段 OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金 } ``` **分配流程**: ``` 1. 平台创建套餐系列,配置一次性佣金规则:首充100返20 2. 平台分配给代理A:设置成本价120,一次性佣金20 → 创建 ShopPackageAllocation(shop_id=A, cost_price=120, one_time_commission_amount=20) 3. 代理A分配给A1:设置成本价130,一次性佣金8 → 创建 ShopPackageAllocation(shop_id=A1, cost_price=130, one_time_commission_amount=8) 4. 触发一次性佣金时: - A1 获得:8 - A 获得:20 - 8 = 12 - 合计:20 ✓ ``` **约束**: - 给下级的金额 ≤ 自己能拿到的金额 - 给下级的金额 ≥ 0 **理由**: - 与成本价分配逻辑一致,都在套餐分配中设置 - 每个代理只存储"自己能拿到多少",计算简单 - 删除了 `ShopSeriesAllocation` 后的自然归属 ### D5: 套餐价格字段简化 **决策**:移除 `Price` 和 `DataAmountMB`,保留并重命名字段 ```go // Before type Package struct { Price int64 // 语义不清 SuggestedCostPrice int64 // 建议成本价 SuggestedRetailPrice int64 // 建议售价 DataType string // real/virtual 二选一 RealDataMB int64 VirtualDataMB int64 DataAmountMB int64 // 语义不清 } // After type Package struct { CostPrice int64 // 成本价(平台设置的基础成本价) SuggestedRetailPrice int64 // 建议售价 RealDataMB int64 // 真实流量(必填) EnableVirtualData bool // 是否启用虚流量 VirtualDataMB int64 // 虚流量(启用时必填,≤ 真实流量) } ``` **理由**: - `Price` 在不同上下文含义不同,造成混乱 - `DataType` 是二选一设计,但业务需要共存 - `DataAmountMB` 没有明确定义是真流量还是虚流量 ### D6: 代理视角套餐列表实现 **决策**:在 Service 层动态计算,从 `ShopPackageAllocation` 获取数据 ```go // PackageService.List 方法 func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) (*dto.PackagePageResult, error) { // 1. 查询基础套餐数据 packages, total, err := s.store.List(ctx, req) // 2. 获取当前用户信息 userInfo := middleware.GetUserContextInfo(ctx) // 3. 如果是代理用户,查询分配关系并填充视角数据 if userInfo.UserType == constants.UserTypeAgent { allocations := s.packageAllocationStore.GetByShopAndPackages(ctx, userInfo.ShopID, packageIDs) for _, pkg := range packages { if alloc, ok := allocations[pkg.ID]; ok { pkg.CostPrice = alloc.CostPrice // 覆盖为代理视角 pkg.OneTimeCommissionAmount = alloc.OneTimeCommissionAmount } } } return result, nil } ``` **理由**: - 不存储冗余数据,避免一致性问题 - 查询时动态计算,逻辑集中在 Service 层 - 使用批量查询避免 N+1 问题 ### D7: 累计充值追踪方案 **决策**:在 `IoTCard` 和 `Device` 模型中新增追踪字段 ```go type IoTCard struct { // ... 现有字段 // 按套餐系列追踪(JSON Map: series_id -> amount) AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb"` // 按套餐系列追踪首充状态(JSON Map: series_id -> bool) FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb"` } ``` **理由**: - 累计充值和首充状态都是"按系列"的,需要按系列追踪 - 使用 JSONB 避免多表关联,查询性能好 - PostgreSQL 原生支持 JSONB 索引和查询 --- ## 删除清单 ### 需要删除的文件 | 文件路径 | 类型 | 说明 | |----------|------|------| | `internal/model/shop_series_allocation.go` | Model | 系列分配模型 | | `internal/model/shop_series_allocation_config.go` | Model | 配置版本模型 | | `internal/model/dto/shop_series_allocation.go` | DTO | 请求/响应 DTO | | `internal/store/postgres/shop_series_allocation_store.go` | Store | 数据访问层 | | `internal/store/postgres/shop_series_allocation_config_store.go` | Store | 配置版本 Store | | `internal/store/postgres/shop_series_allocation_store_test.go` | Test | Store 测试 | | `internal/service/shop_series_allocation/service.go` | Service | 业务逻辑层 | | `internal/handler/admin/shop_series_allocation.go` | Handler | HTTP Handler | | `internal/routes/shop_series_allocation.go` | Routes | 路由注册 | | `tests/integration/shop_series_allocation_test.go` | Test | 集成测试 | | `pkg/utils/commission.go` | Utils | CalculateCostPrice 函数 | ### 需要删除的字段 | 表/模型 | 字段 | 说明 | |---------|------|------| | `ShopPackageAllocation` | `AllocationID` | 外键到 ShopSeriesAllocation | | `ShopSeriesAllocation` | 整表 | 删除整个表 | | `ShopSeriesAllocationConfig` | 整表 | 删除整个表 | ### 需要修改的文件 见 `tasks.md` 中的详细任务列表。 --- ## Risks / Trade-offs ### R1: 数据迁移复杂度 **风险**:现有数据结构与新结构差异大,迁移可能导致数据丢失或不一致 **缓解措施**: - 分阶段迁移:先新增字段,再迁移数据,最后删除旧字段 - 迁移前完整备份 - 迁移脚本支持回滚 - 新旧字段并存过渡期(2周) ### R2: API 破坏性变更 **风险**:前端需要同步修改,上线需要协调 **缓解措施**: - 提前沟通 API 变更内容 - 删除 `/api/admin/shop-series-allocations/*` 路由 - 修改 `/api/admin/shop-package-allocations/*` 路由参数 - 提供详细的迁移文档 ### R3: 链式分配计算性能 **风险**:触发一次性佣金时需要沿代理链向上计算,可能涉及多级查询 **缓解措施**: - 使用 Redis 缓存代理链关系 - 限制代理层级(最多 7 级) - 佣金分配使用异步任务处理 ### R4: JSONB 字段查询性能 **风险**:累计充值和首充状态使用 JSONB 存储,复杂查询可能慢 **缓解措施**: - 为常用查询路径创建 JSONB 索引 - 触发检查时先查内存/Redis 缓存 - 监控查询性能,必要时重构为独立表 --- ## Migration Plan **注意**:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。 ### 数据库变更 ```sql -- 1. ShopPackageAllocation 新增字段 ALTER TABLE tb_shop_package_allocation ADD COLUMN IF NOT EXISTS shop_id BIGINT, ADD COLUMN IF NOT EXISTS series_id BIGINT, ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0, ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0; -- 2. ShopPackageAllocation 删除字段 ALTER TABLE tb_shop_package_allocation DROP COLUMN IF EXISTS allocation_id; -- 3. Package 表调整 ALTER TABLE tb_package DROP COLUMN IF EXISTS price, DROP COLUMN IF EXISTS data_type, DROP COLUMN IF EXISTS data_amount_mb, ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false; -- 4. IoTCard 新增追踪字段 ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}', ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}'; -- 5. Device 新增追踪字段 ALTER TABLE tb_device ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}', ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}'; -- 6. 删除废弃表 DROP TABLE IF EXISTS tb_shop_series_allocation; DROP TABLE IF EXISTS tb_shop_series_allocation_config; DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier; ``` ### 代码变更顺序 1. **Model 层** - 修改 `ShopPackageAllocation` 模型 - 删除 `ShopSeriesAllocation` 相关模型 2. **DTO 层** - 修改 `ShopPackageAllocation` DTO - 删除 `ShopSeriesAllocation` DTO 3. **Store 层** - 修改 `ShopPackageAllocationStore` - 删除 `ShopSeriesAllocationStore` 4. **Service 层** - 修改所有依赖 `ShopSeriesAllocation` 的 Service - 删除 `ShopSeriesAllocationService` 5. **Handler/Routes 层** - 删除 `ShopSeriesAllocationHandler` - 删除相关路由注册 6. **Bootstrap 层** - 移除所有 `ShopSeriesAllocation` 相关初始化 --- ## Open Questions 1. **历史订单佣金**:已完成的订单佣金是否需要按新规则重算? - 建议:不重算,保持历史数据稳定 2. **过渡期时长**:新旧字段并存多久? - 建议:2周观察期,确认无问题后清理 3. **前端发版协调**:是否需要灰度发布? - 取决于前端改动量,建议同步上线