## Context 当前系统中,"给代理分配套餐"需要两步独立操作:先调用 `POST /shop-series-allocations` 创建系列分配,再多次调用 `POST /shop-package-allocations` 逐一分配套餐。 `ShopSeriesAllocation` 有 6 个字段存在问题: - 前 3 个死字段(`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold`)从未被计算引擎读取,与 PackageSeries 配置完全重复 - 强充 3 个字段(`enable_force_recharge`、`force_recharge_amount`、`force_recharge_trigger_type`)语义正确,但 `checkForceRechargeRequirement` 只读 PackageSeries,代理配置完全无效 梯度模式下,`calculateChainOneTimeCommission` 直接把 PackageSeries 全局 tiers 传给 `matchOneTimeCommissionTier`,即所有代理用同一套阶梯金额。但业务模型要求每个代理有自己的专属阶梯金额(上级可以把金额压低给下级,但阈值不变),数据库中完全没有存储这个信息的字段。 ## Goals / Non-Goals **Goals:** - 新增统一的"系列授权" API,一次请求原子性完成系列分配 + 套餐列表分配 - 修复强充层级:平台未设强充时,销售代理自设的强充配置真正生效 - 修复梯度模式数据模型:新增 `commission_tiers_json` 字段存储每代理专属阶梯金额 - 修复梯度模式计算引擎:读取各代理自己的阶梯金额,而非全局 tiers - 新增金额上限校验:固定模式单值天花板;梯度模式每档位天花板 - 清理 3 个语义重复的死字段(数据库迁移删除) - 删除旧的 `/shop-series-allocations` 和 `/shop-package-allocations` 接口,干净重构 **Non-Goals:** - 不重写现有的佣金计算链式逻辑(数学结构正确,只改数据读取来源) - 不修改 ShopPackageBatchAllocation、ShopPackageBatchPricing 这两个 Service(批量操作不在本次范围) - 不修改差价佣金(`CalculateCostDiffCommission`)逻辑 - 不改变数据存储底层结构(仍是两张表) ## Decisions ### 决策 1:新接口策略——外观层 **选择**:新增 `ShopSeriesGrantService`,内部事务性地调用已有 `ShopSeriesAllocationStore` 和 `ShopPackageAllocationStore`。 **理由**:底层两张表的数据结构不变,对外以"授权"概念聚合呈现。 ### 决策 2:梯度模式数据模型——JSONB 字段 **选择**:在 `ShopSeriesAllocation` 新增 `commission_tiers_json JSONB` 字段,存储该代理的专属阶梯金额列表。 数据格式: ```json [ {"threshold": 100, "amount": 80}, {"threshold": 150, "amount": 120} ] ``` - `threshold` 必须与 PackageSeries 全局 tiers 的阈值一一对应(不允许创建不存在的阈值) - `amount` 由上级在授权时设定,不得超过上级同阈值的 amount - 固定模式下此字段为空数组 `[]` **理由**:阶梯金额是每代理独立的配置,与分配记录 1:1 关联,JSONB 存储简洁无需额外联表;阈值条件(dimension、stat_scope、threshold 数值)全局一致,无需在分配层重复存储。 **备选方案**:新建 `tb_shop_series_allocation_tier` 子表 → 被否决,阈值不可修改且数量固定(跟随系列配置),子表增加了查询复杂度。 ### 决策 3:梯度模式计算引擎改写 **原逻辑**(错误): ``` matchOneTimeCommissionTier(ctx, shopID, seriesID, allocationID, config.Tiers) ↑ PackageSeries 全局阶梯金额 ``` **新逻辑**: ``` 1. 从 PackageSeries 取 tiers(dimension、stat_scope、threshold 列表) 2. 查询当前代理的 ShopSeriesAllocation.commission_tiers_json(专属金额) 3. 根据该代理的销售统计匹配命中的 threshold 4. 从步骤 2 的专属金额列表中取出该 threshold 对应的 amount → 即 myAmount ``` 阶梯金额的来源由全局改为各代理自己的记录,dimension/stat_scope/threshold 仍从全局读取。 ### 决策 4:统一授权响应格式 `GET /shop-series-grants/:id` 返回聚合视图: ``` ShopSeriesGrantResponse { id // ShopSeriesAllocation.ID shop_id / shop_name series_id / series_name / series_code commission_type // "fixed" 或 "tiered",从 PackageSeries 读取 one_time_commission_amount // 固定模式:该代理的天花板;梯度模式:返回 0 commission_tiers // 梯度模式:专属阶梯列表;固定模式:空数组 force_recharge_locked // true=平台已设强充,前端只读 force_recharge_enabled force_recharge_amount allocator_shop_id / allocator_shop_name status packages: [{ package_id, package_name, package_code, cost_price, shelf_status, status }] created_at / updated_at } ``` ### 决策 5:金额上限校验规则 **固定模式**: ``` allocator_shop_id == 0(平台)→ one_time_commission_amount ≤ PackageSeries.commission_amount allocator_shop_id > 0(代理)→ one_time_commission_amount ≤ 分配者自身的 one_time_commission_amount ``` **梯度模式**: ``` 对请求中每一个 {threshold, amount}: allocator_shop_id == 0(平台)→ amount ≤ PackageSeries 同 threshold 的 amount allocator_shop_id > 0(代理)→ amount ≤ 分配者的 commission_tiers_json 中同 threshold 的 amount ``` ### 决策 6:强充层级的实现位置 在 `order/service.go` 的 `checkForceRechargeRequirement` 中增加: ``` 1. 从 PackageSeries 获取配置,config.Enable == false → 无强充 2. firstCommissionPaid == true → 无强充 3. config.TriggerType == "first_recharge": → 直接返回需要强充(amount = config.Threshold) → 首次充值本身即为强充机制,代理无法修改此行为 → 创建授权时忽略代理传入的 enable_force_recharge,响应 force_recharge_locked=true 4. config.TriggerType == "accumulated_recharge": a. config.EnableForceRecharge == true → 使用平台强充(amount = config.ForceAmount),force_recharge_locked=true b. config.EnableForceRecharge == false → 查询 result.Card.ShopID(或 result.Device.ShopID)的 ShopSeriesAllocation → 若 allocation.EnableForceRecharge == true → 使用代理强充配置 → 否则 → 无强充 ``` **Grant API 强充字段的可写条件**: - `TriggerType == first_recharge`:`enable_force_recharge` 字段无效,创建/更新时忽略,响应 `force_recharge_locked=true` - `TriggerType == accumulated_recharge` + 平台已设:同上,`force_recharge_locked=true` - `TriggerType == accumulated_recharge` + 平台未设:`enable_force_recharge` 和 `force_recharge_amount` 生效,`force_recharge_locked=false` ### 决策 7:删除旧接口范围 删除以下内容(开发阶段干净重构): - `internal/handler/admin/shop_series_allocation.go` - `internal/handler/admin/shop_package_allocation.go` - `internal/routes/shop_series_allocation.go` - `internal/routes/shop_package_allocation.go` - `internal/model/dto/shop_series_allocation.go` - `internal/model/dto/shop_package_allocation.go` - `internal/service/shop_series_allocation/` - `internal/service/shop_package_allocation/` - 从 `bootstrap/types.go`、`bootstrap/handlers.go`、`bootstrap/services.go`、`pkg/openapi/handlers.go`、`routes/admin.go` 中移除相关引用 保留(不删除): - 两个 Store(`ShopSeriesAllocationStore`、`ShopPackageAllocationStore`)——被佣金计算、订单服务、新 Grant Service 使用 - `ShopPackageBatchAllocation`、`ShopPackageBatchPricing` Service + Handler + routes(批量操作不在本次范围) ### 决策 8:数据库迁移策略 单次迁移文件完成: - 删除 3 列:`enable_one_time_commission`、`one_time_commission_trigger`、`one_time_commission_threshold` - 新增 1 列:`commission_tiers_json JSONB NOT NULL DEFAULT '[]'` 迁移顺序:先部署代码(新代码不再读写 3 个死字段,新字段有默认值),再执行迁移,规避滚动部署期间的字段缺失问题。DOWN 脚本不可逆(不恢复数据)。 ### 决策 9:梯度阶梯运算符支持 **背景**:当前 `matchOneTimeCommissionTier` 对所有阶梯固定使用 `>=` 做阈值比较,无法支持不同运算符(如"销量 < 100 给 X 元")。 **选择**:在 `OneTimeCommissionTier` 结构体新增 `Operator string` 字段,支持 `>`、`>=`、`<`、`<=`,默认值 `>=`(向前兼容)。 **存储位置**: - `Operator` 仅存于 `PackageSeries.config.Tiers`(全局,由套餐系列配置决定) - `ShopSeriesAllocation.commission_tiers_json` 不存 `Operator`(代理不能修改条件,只能修改金额) - Grant 响应的 `commission_tiers` 展示时,将 `Operator` 从 PackageSeries 全局 tiers 按 threshold 合并进来 **计算引擎**:`matchOneTimeCommissionTier` 根据 `Operator` 选择对应比较函数;`Operator` 为空时 fallback 到 `>=`。 **不修改**:PackageSeries 的创建/编辑 API 不在本次范围(通过直接写 JSONB 设置 operator)。 ## Risks / Trade-offs | 风险 | 缓解措施 | |------|----------| | 旧接口删除后前端需同步更新 | 开发阶段删除无历史数据顾虑,与前端对齐后统一上线 | | 现有梯度模式历史分配记录的 commission_tiers_json 为空 | 新字段 DEFAULT '[]',计算引擎遇到空数组时 fallback 到 one_time_commission_amount(存 0)→ 不发佣金,需在上线前批量补填历史数据或确认历史记录均为固定模式 | | checkForceRechargeRequirement 新增 DB 查询 | 函数已有 2 次查询,增加 1 次;量级可接受,后续可加缓存 | | 梯度模式计算引擎改写影响正在运行的佣金计算 | 新逻辑仅在 commission_tiers_json 有值时生效;历史空记录走 fallback,不影响已发放佣金 |