Files
junhong_cmp_fiber/openspec/changes/archive/2026-03-04-refactor-agent-series-grant/design.md
huang e0cb4498e6 docs: 归档 refactor-agent-series-grant 变更文档
将已完成的变更(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>
2026-03-04 11:37:33 +08:00

9.7 KiB
Raw Blame History

Context

当前系统中,"给代理分配套餐"需要两步独立操作:先调用 POST /shop-series-allocations 创建系列分配,再多次调用 POST /shop-package-allocations 逐一分配套餐。

ShopSeriesAllocation 有 6 个字段存在问题:

  • 前 3 个死字段(enable_one_time_commissionone_time_commission_triggerone_time_commission_threshold)从未被计算引擎读取,与 PackageSeries 配置完全重复
  • 强充 3 个字段(enable_force_rechargeforce_recharge_amountforce_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,内部事务性地调用已有 ShopSeriesAllocationStoreShopPackageAllocationStore

理由:底层两张表的数据结构不变,对外以"授权"概念聚合呈现。

决策 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 取 tiersdimension、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.gocheckForceRechargeRequirement 中增加:

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.ForceAmountforce_recharge_locked=true
   b. config.EnableForceRecharge == false
      → 查询 result.Card.ShopID或 result.Device.ShopID的 ShopSeriesAllocation
      → 若 allocation.EnableForceRecharge == true → 使用代理强充配置
      → 否则 → 无强充

Grant API 强充字段的可写条件

  • TriggerType == first_rechargeenable_force_recharge 字段无效,创建/更新时忽略,响应 force_recharge_locked=true
  • TriggerType == accumulated_recharge + 平台已设:同上,force_recharge_locked=true
  • TriggerType == accumulated_recharge + 平台未设:enable_force_rechargeforce_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.gobootstrap/handlers.gobootstrap/services.gopkg/openapi/handlers.goroutes/admin.go 中移除相关引用

保留(不删除):

  • 两个 StoreShopSeriesAllocationStoreShopPackageAllocationStore)——被佣金计算、订单服务、新 Grant Service 使用
  • ShopPackageBatchAllocationShopPackageBatchPricing Service + Handler + routes批量操作不在本次范围

决策 8数据库迁移策略

单次迁移文件完成:

  • 删除 3 列:enable_one_time_commissionone_time_commission_triggerone_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不影响已发放佣金