将已完成的变更(proposal、design、tasks、delta specs)归档至 openspec/changes/archive/2026-03-04-refactor-agent-series-grant/。变更内容:合并系列分配和套餐分配为系列授权(Grant)、新增梯度佣金模式、新增代理层强充层级规则。50/50 任务全部完成。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
9.7 KiB
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 字段,存储该代理的专属阶梯金额列表。
数据格式:
[
{"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=trueTriggerType == accumulated_recharge+ 平台已设:同上,force_recharge_locked=trueTriggerType == accumulated_recharge+ 平台未设:enable_force_recharge和force_recharge_amount生效,force_recharge_locked=false
决策 7:删除旧接口范围
删除以下内容(开发阶段干净重构):
internal/handler/admin/shop_series_allocation.gointernal/handler/admin/shop_package_allocation.gointernal/routes/shop_series_allocation.gointernal/routes/shop_package_allocation.gointernal/model/dto/shop_series_allocation.gointernal/model/dto/shop_package_allocation.gointernal/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、ShopPackageBatchPricingService + 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,不影响已发放佣金 |