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>
This commit is contained in:
2026-03-04 11:37:33 +08:00
parent c7b8ecfebf
commit e0cb4498e6
7 changed files with 695 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-03

View File

@@ -0,0 +1,188 @@
## 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 取 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.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.ForceAmountforce_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不影响已发放佣金 |

View File

@@ -0,0 +1,54 @@
## Why
代理套餐授权体系存在四类问题:
1. **接口割裂**:给代理授权套餐需要两步独立 API先创建系列分配再多次创建套餐分配但这本质是一个业务动作
2. **死字段**`ShopSeriesAllocation` 有 3 个字段(`enable_one_time_commission``one_time_commission_trigger``one_time_commission_threshold`)从未被计算引擎读取,与 PackageSeries 配置语义完全重复
3. **强充逻辑未接入**:代理自设强充的 3 个字段虽然存储,但 `checkForceRechargeRequirement` 从不读取,导致代理无论如何设置都不生效
4. **梯度模式实现错误**:梯度佣金计算引擎用 PackageSeries 全局阶梯表(所有代理同一套金额),但业务模型要求每个代理有自己的专属阶梯金额(可压缩、不超父级同档位上限);且数据库中没有存储每代理专属阶梯金额的字段
## What Changes
- **新增** 统一"系列授权"接口(`/shop-series-grants`),一次操作原子性完成系列分配 + 套餐列表分配,底层仍为两张表
- **删除** 旧的 `/shop-series-allocations``/shop-package-allocations` 接口及其全部 Handler、routes、DTO、Service开发阶段干净重构
- **修复** 强充层级:订单服务接入销售代理的 `ShopSeriesAllocation` 强充配置,实现"平台未设强充 → 代理自设生效"
- **修复** 梯度模式数据模型:`ShopSeriesAllocation` 新增 `commission_tiers_json` JSONB 字段,存储每代理专属阶梯金额
- **修复** 梯度模式计算引擎:读取各代理自己的阶梯金额,而不是 PackageSeries 全局阶梯
- **新增** 金额上限校验:固定模式——子级 `one_time_commission_amount` 不得超过父级;梯度模式——子级每档位金额不得超过父级同档位金额
- **清理** 删除 3 个死字段(`enable_one_time_commission``one_time_commission_trigger``one_time_commission_threshold`
## Capabilities
### New Capabilities
- `agent-series-grant`:统一代理系列授权 CRUD单次操作包含套餐系列 + 套餐列表 + 成本价 + 一次性佣金配置(固定模式:单个上限金额;梯度模式:每档位上限金额列表)+ 强充配置(平台未设时)
### Modified Capabilities
- `force-recharge-check`:强充检查增加层级判断——平台系列已设强充时沿用,未设时改为读取销售代理的 `ShopSeriesAllocation` 强充配置
### Removed Capabilities
- `shop-series-allocation`旧系列分配接口全部删除Handler、routes、DTO、Service
- `shop-package-allocation`旧套餐分配接口全部删除Handler、routes、DTO、Service
## Impact
**受影响的数据库表**
- `tb_shop_series_allocation`
- 删除 3 列enable_one_time_commission、one_time_commission_trigger、one_time_commission_threshold
- 新增 1 列commission_tiers_json JSONB梯度模式专用
**受影响的 API**
- 新增:`POST/GET/PUT/DELETE /api/admin/shop-series-grants` 系列
- 新增:`PUT /api/admin/shop-series-grants/:id/packages`
- 删除:`/api/admin/shop-series-allocations` 全部接口
- 删除:`/api/admin/shop-package-allocations` 全部接口
- 变更:强充预检接口(`/purchase-check``/wallet-recharge-check`)返回结果受新逻辑影响
**受影响的服务**
- `internal/service/order/service.go``checkForceRechargeRequirement` 增加代理分配查询
- `internal/service/commission_calculation/service.go`:梯度模式改为读取各代理专属阶梯金额
- 删除:`internal/service/shop_series_allocation/`
- 删除:`internal/service/shop_package_allocation/`
- 新增:`internal/service/shop_series_grant/`

View File

@@ -0,0 +1,181 @@
# Capability: 代理系列授权管理
## Purpose
定义"系列授权"Series Grant的 CRUD 操作。系列授权将原本割裂的"系列分配"和"套餐分配"合并为一个原子操作,一次请求完成:授权代理可销售某系列下的指定套餐、设定每个套餐的成本价、配置一次性佣金(固定模式:单值天花板;梯度模式:每档位上限金额列表)和代理自设强充(平台未设时)。
底层仍使用 `tb_shop_series_allocation``tb_shop_package_allocation` 两张表,对外以 `ShopSeriesAllocation.ID` 作为 grant 主键。
---
## Requirements
### Requirement: 创建系列授权(固定模式)
系统 SHALL 提供 `POST /shop-series-grants` 接口,在一次请求中原子性创建系列分配和套餐分配列表。固定模式下 `one_time_commission_amount` MUST 必填,且不得超过分配者自身的天花板。
#### Scenario: 代理成功创建固定模式授权
- **WHEN** 代理A自身天花板=80元为直属下级代理B 创建系列授权commission_type=fixedone_time_commission_amount=500050元packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}]
- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocationone_time_commission_amount=5000和 2 条 ShopPackageAllocation响应返回包含 packages 列表的聚合视图
#### Scenario: 代理B 已存在此系列授权,重复创建
- **WHEN** 代理A 为代理B 创建系列授权但代理B 在此系列下已有 active 授权记录
- **THEN** 系统返回错误"该代理已存在此系列授权"
#### Scenario: 分配者自身无此系列授权
- **WHEN** 代理A 自身未被授权此套餐系列尝试为代理B 创建此系列授权
- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配"
#### Scenario: 平台成功创建固定模式授权
- **WHEN** 平台管理员为一级代理创建系列授权commission_type=fixedone_time_commission_amount=800080元系列总额 commission_amount=10000100元
- **THEN** 系统创建授权,响应中 allocator_shop_id=0allocator_shop_name="平台"
#### Scenario: 固定模式 one_time_commission_amount 为必填
- **WHEN** 请求中不包含 one_time_commission_amount
- **THEN** 系统返回参数错误"固定模式下一次性佣金额度为必填项"
#### Scenario: 金额超过代理自身天花板
- **WHEN** 代理A天花板=8000分为代理B 创建授权one_time_commission_amount=10000
- **THEN** 系统返回错误"一次性佣金额度不能超过上级限额"
#### Scenario: 平台金额超过系列总额
- **WHEN** 平台为代理A 创建授权one_time_commission_amount=12000但 PackageSeries.commission_amount=10000
- **THEN** 系统返回错误"一次性佣金额度不能超过套餐系列设定的总额"
---
### Requirement: 创建系列授权(梯度模式)
系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0不可省略该档位。
#### Scenario: 代理成功创建梯度模式授权
- **WHEN** 代理A 的专属阶梯为 [{operator:">=", threshold:100, amount:80}, {operator:">=", threshold:150, amount:120}]A 为代理B 创建授权,传入 commission_tiers=[{threshold:100, amount:50}, {threshold:150, amount:100}]
- **THEN** 系统创建授权commission_tiers_json 存储 [{threshold:100, amount:50}, {threshold:150, amount:100}],响应中 commission_tiers=[{operator:">=", threshold:100, amount:50}, {operator:">=", threshold:150, amount:100}]operator 从 PackageSeries 读取后合并)
#### Scenario: 平台成功创建梯度模式授权
- **WHEN** 平台为顶级代理A 创建授权PackageSeries 阶梯为 [{operator:">=", threshold:100, amount:100}, {operator:"<", threshold:50, amount:30}],传入 commission_tiers=[{threshold:100, amount:80}, {threshold:50, amount:20}]
- **THEN** 系统创建授权A 的专属阶梯存入 commission_tiers_json响应中 commission_tiers 包含对应的 operator
#### Scenario: 梯度模式某档位金额超过父级
- **WHEN** 代理A 的阶梯第一档 amount=80A 为 B 创建授权时传入第一档 amount=90
- **THEN** 系统返回错误"梯度佣金档位金额不能超过上级同档位限额"
#### Scenario: 梯度模式传入了不存在的阈值
- **WHEN** PackageSeries 只有 threshold=100 和 150 两档,请求中传入 threshold=200
- **THEN** 系统返回错误"阶梯阈值与系列配置不匹配"
#### Scenario: 梯度模式 commission_tiers 为必填
- **WHEN** 请求中不包含 commission_tiers 或为空数组
- **THEN** 系统返回参数错误"梯度模式下必须提供阶梯金额配置"
---
### Requirement: 强充配置的平台/代理层级
创建系列授权时,系统 SHALL 根据 PackageSeries 的触发类型和强充设置决定代理是否可自设强充。
#### Scenario: 首次充值触发类型,强充不可配置
- **WHEN** PackageSeries.trigger_type=first_recharge代理创建授权时传入任意 enable_force_recharge 值
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true首次充值本身即为强充机制无需额外配置
#### Scenario: 累计充值触发类型,平台已设强充,代理配置被忽略
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=true代理创建授权时传入 enable_force_recharge=false
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true
#### Scenario: 累计充值触发类型,平台未设强充,代理可自设
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=false代理创建授权时传入 enable_force_recharge=trueforce_recharge_amount=10000
- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=falseforce_recharge_enabled=trueforce_recharge_amount=10000
---
### Requirement: 查询系列授权详情
系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。
#### Scenario: 固定模式详情
- **WHEN** 查询固定模式系列授权详情
- **THEN** 响应包含 commission_type="fixed"one_time_commission_amount=有效值commission_tiers=[]
#### Scenario: 梯度模式详情
- **WHEN** 查询梯度模式系列授权详情
- **THEN** 响应包含 commission_type="tiered"one_time_commission_amount=0commission_tiers=[{threshold, amount}, ...]
#### Scenario: 查询不存在的授权
- **WHEN** 查询不存在的授权 ID
- **THEN** 系统返回错误"授权记录不存在"
---
### Requirement: 查询系列授权列表
系统 SHALL 提供 `GET /shop-series-grants` 接口,支持分页和多维度筛选,响应内嵌套餐数量摘要(不含完整套餐列表)。
#### Scenario: 列表查询支持按店铺和系列筛选
- **WHEN** 传入 shop_id、series_id、allocator_shop_id 等筛选条件
- **THEN** 仅返回符合条件的授权记录,每条记录包含 package_count
---
### Requirement: 更新系列授权配置
系统 SHALL 提供 `PUT /shop-series-grants/:id` 接口,支持更新一次性佣金配置和强充配置。
#### Scenario: 固定模式更新佣金额度
- **WHEN** 更新 one_time_commission_amount新值不超过分配者天花板
- **THEN** 系统更新成功
#### Scenario: 梯度模式更新阶梯金额
- **WHEN** 更新 commission_tiers每档位金额不超过分配者同档位上限
- **THEN** 系统更新 commission_tiers_json 字段
#### Scenario: 更新代理自设强充(平台未设时)
- **WHEN** 平台未设强充,更新 enable_force_recharge=trueforce_recharge_amount=10000
- **THEN** 系统更新成功,后续该代理渠道下的客户须满足强充要求
---
### Requirement: 管理授权内套餐
系统 SHALL 提供 `PUT /shop-series-grants/:id/packages` 接口,支持添加套餐、移除套餐、更新成本价,操作在事务中完成,成功后返回 HTTP 200无需返回完整授权视图
#### Scenario: 向授权中添加新套餐
- **WHEN** 请求包含新的 package_id 和 cost_price且该套餐属于此系列
- **THEN** 系统创建新的 ShopPackageAllocation
#### Scenario: 更新套餐成本价
- **WHEN** 请求中套餐的 cost_price 与当前值不同
- **THEN** 系统更新 cost_price 并写价格历史记录
#### Scenario: 移除授权中的套餐
- **WHEN** 请求中某套餐标记 remove=true且该套餐在当前授权中存在
- **THEN** 系统软删除对应的 ShopPackageAllocation
#### Scenario: remove=true 但套餐已不在授权中
- **WHEN** 请求中某套餐标记 remove=true但该套餐已被软删除或从未在此授权中
- **THEN** 系统静默忽略该条目,不报错,继续处理其他条目
#### Scenario: 重新添加曾被移除的套餐
- **WHEN** 某套餐曾经被软删除,请求中再次包含该 package_id无 remove 标志)
- **THEN** 系统创建一条新的 ShopPackageAllocation 记录,不恢复旧记录
#### Scenario: 添加不属于该系列的套餐
- **WHEN** 请求中包含不属于该系列的 package_id
- **THEN** 系统返回错误"套餐不属于该系列,无法添加到此授权"
#### Scenario: 添加上级未授权的套餐
- **WHEN** 代理A 尝试添加代理A 自己也未获授权的套餐
- **THEN** 系统返回错误"无权限分配该套餐"
---
### Requirement: 删除系列授权
系统 SHALL 提供 `DELETE /shop-series-grants/:id` 接口,删除时同步软删除所有关联的套餐分配。
#### Scenario: 成功删除无下级依赖的授权
- **WHEN** 删除一个下级代理未基于此授权再分配的记录
- **THEN** 系统软删除 ShopSeriesAllocation 和所有关联的 ShopPackageAllocation
#### Scenario: 有下级依赖时禁止删除
- **WHEN** 删除一个已被下级代理用于创建子授权的记录
- **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"

View File

@@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: 代理层强充层级判断
系统 SHALL 在强充预检时按层级判断生效的强充配置:平台在 PackageSeries 中设置的强充具有最高优先级;平台未设强充时,读取客户所属销售代理(`order.SellerShopID`)对应的 ShopSeriesAllocation 强充配置。
#### Scenario: 平台已设强充,代理自设被忽略
- **WHEN** PackageSeries.enable_force_recharge=true平台层客户在代理A 的渠道下购买代理A 的 ShopSeriesAllocation.enable_force_recharge=false
- **THEN** 系统使用平台强充规则need_force_recharge=trueforce_recharge_amount=平台设定值
#### Scenario: 平台未设强充,代理自设生效
- **WHEN** PackageSeries.enable_force_recharge=false客户在代理A 的渠道下购买代理A 的 ShopSeriesAllocation.enable_force_recharge=trueforce_recharge_amount=10000
- **THEN** 系统使用代理A 的强充配置need_force_recharge=trueforce_recharge_amount=10000
#### Scenario: 平台未设强充,代理也未设强充
- **WHEN** PackageSeries.enable_force_recharge=false代理A 的 ShopSeriesAllocation.enable_force_recharge=false
- **THEN** 系统返回 need_force_recharge=false
#### Scenario: 平台未设强充,查询不到销售代理分配
- **WHEN** PackageSeries.enable_force_recharge=false系统查询不到 SellerShop 对应的 ShopSeriesAllocation
- **THEN** 系统返回 need_force_recharge=false降级处理不影响购买流程
---
## MODIFIED Requirements
### Requirement: 钱包充值预检
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。强充判断 MUST 按代理层级规则执行:优先使用平台强充,平台未设时使用销售代理自设强充。
#### Scenario: 无强充要求
- **WHEN** 客户查询卡钱包充值预检PackageSeries.enable_force_recharge=false销售代理 ShopSeriesAllocation.enable_force_recharge=false
- **THEN** 系统返回 need_force_recharge=false
#### Scenario: 首次充值强充(平台层)
- **WHEN** 客户查询卡钱包充值预检PackageSeries 配置为首次充值触发,阈值 10000 分,未发放佣金
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=10000trigger_type="single_recharge"
#### Scenario: 累计充值启用强充(平台层)
- **WHEN** 客户查询卡钱包充值预检PackageSeries.enable_force_recharge=trueforce_amount=10000
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=10000trigger_type="accumulated_recharge"
#### Scenario: 代理自设累计充值强充(平台未设)
- **WHEN** PackageSeries.enable_force_recharge=false销售代理的 ShopSeriesAllocation.enable_force_recharge=trueforce_recharge_amount=8000
- **THEN** 系统返回 need_force_recharge=trueforce_recharge_amount=8000
#### Scenario: 一次性佣金已发放
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
- **THEN** 系统返回 need_force_recharge=false不再强充
#### Scenario: 未启用一次性佣金
- **WHEN** 客户查询卡钱包充值预检,卡关联系列未启用一次性佣金
- **THEN** 系统返回 need_force_recharge=false
---
### Requirement: 套餐购买预检
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。强充判断 MUST 按代理层级规则执行。
#### Scenario: 无强充要求正常购买
- **WHEN** 客户购买 90 元套餐,平台和销售代理均未设强充
- **THEN** 系统返回 total_package_amount=9000need_force_recharge=falseactual_payment=9000wallet_credit=0
#### Scenario: 代理自设强充,套餐价低于强充金额
- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000
- **THEN** 系统返回 actual_payment=10000wallet_credit=5000
#### Scenario: 首次充值强充(平台层),套餐价低于阈值
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层)
- **THEN** 系统返回 total_package_amount=9000need_force_recharge=trueforce_recharge_amount=10000actual_payment=10000wallet_credit=1000
#### Scenario: 购买多个套餐
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
- **THEN** 系统返回 total_package_amount=12000actual_payment=12000wallet_credit=0

View File

@@ -0,0 +1,37 @@
## REMOVED Requirements
### Requirement: /shop-series-allocations 接口
**Reason**: 已被 `/shop-series-grants` 完全替代。开发阶段干净重构,不保留兼容接口。
**删除范围**
- `internal/handler/admin/shop_series_allocation.go`
- `internal/routes/shop_series_allocation.go`
- `internal/model/dto/shop_series_allocation.go`
- `internal/service/shop_series_allocation/`
-`bootstrap/types.go``bootstrap/handlers.go``bootstrap/services.go``pkg/openapi/handlers.go``routes/admin.go` 移除引用
**保留**`internal/store/postgres/shop_series_allocation_store.go`被佣金计算、订单服务、Grant Service 使用)
---
### Requirement: /shop-package-allocations 接口
**Reason**: 套餐分配已合并进 `/shop-series-grants` 的创建和套餐管理接口。开发阶段干净重构,不保留兼容接口。
**删除范围**
- `internal/handler/admin/shop_package_allocation.go`
- `internal/routes/shop_package_allocation.go`
- `internal/model/dto/shop_package_allocation.go`
- `internal/service/shop_package_allocation/`
- 从 bootstrap、openapi/handlers、routes/admin 移除引用
**保留**`internal/store/postgres/shop_package_allocation_store.go`(被多处使用)
---
### Requirement: 分配时配置 enable_one_time_commission 等字段
**Reason**: `enable_one_time_commission``one_time_commission_trigger``one_time_commission_threshold` 三个字段从未被计算引擎读取,与 PackageSeries 的配置语义完全重复。
**Migration**:一次性佣金是否启用由 `PackageSeries.enable_one_time_commission` 控制;分配表中仅保留 `one_time_commission_amount`(固定模式天花板)、`commission_tiers_json`(梯度模式专属阶梯)和强充 3 个字段。

View File

@@ -0,0 +1,158 @@
## 1. 数据库迁移文件准备
- [x] 1.1 使用 db-migration 规范创建迁移文件:
- **删除** `tb_shop_series_allocation` 的 3 列:`enable_one_time_commission``one_time_commission_trigger``one_time_commission_threshold`
- **新增** `commission_tiers_json JSONB NOT NULL DEFAULT '[]'`(梯度模式专属阶梯金额)
- DOWN 脚本添加说明注释(不恢复数据)
## 2. 删除旧接口Handler / routes / DTO / Service
- [x] 2.1 删除文件:
- `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/`(整个目录)
- [x] 2.2 `internal/bootstrap/types.go`:删除 `ShopSeriesAllocation``ShopPackageAllocation` 两个 Handler 字段
- [x] 2.3 `internal/bootstrap/handlers.go`:删除对应 Handler 初始化行;删除对应 service 引用
- [x] 2.4 `internal/bootstrap/services.go`:删除 `ShopSeriesAllocation``ShopPackageAllocation` 两个 Service 字段及初始化;移除对应 import
- [x] 2.5 `pkg/openapi/handlers.go`:删除 `ShopSeriesAllocation``ShopPackageAllocation` 两行
- [x] 2.6 `internal/routes/admin.go`:删除 `registerShopSeriesAllocationRoutes``registerShopPackageAllocationRoutes` 两处调用
- [x] 2.7 运行 `go build ./...` 确认无编译错误
## 3. Model 更新
- [x] 3.1 `internal/model/shop_series_allocation.go`
- 删除 `EnableOneTimeCommission``OneTimeCommissionTrigger``OneTimeCommissionThreshold` 三个字段
- 新增 `CommissionTiersJSON string` 字段JSONB默认 `'[]'`
- 新增辅助类型 `AllocationCommissionTier struct { Threshold int64; Amount int64 }`
- 新增 `GetCommissionTiers() ([]AllocationCommissionTier, error)` 方法
- 新增 `SetCommissionTiers(tiers []AllocationCommissionTier) error` 方法
- [x] 3.2 `internal/model/package.go`
- `OneTimeCommissionTier` 新增 `Operator string` 字段json:"operator"
- 新增运算符常量:`TierOperatorGT = ">"` / `TierOperatorGTE = ">="` / `TierOperatorLT = "<"` / `TierOperatorLTE = "<="`
- [x] 3.3 运行 `go build ./...` 确认无编译错误
## 4. 修复梯度模式计算引擎
- [x] 4.1 `internal/service/commission_calculation/service.go`
修改 `calculateChainOneTimeCommission` 中梯度模式分支:
- 原来:直接把 `config.Tiers`(全局)传给 `matchOneTimeCommissionTier`
- 新的:从 `currentSeriesAllocation.GetCommissionTiers()` 取专属金额列表,结合 `config.Tiers`(取 operator/dimension/stat_scope/threshold做匹配
- 匹配逻辑:根据代理销售统计和 tier.Operator 判断是否命中 threshold → 查专属列表同 threshold 的 amount → 即 myAmount未命中任何阶梯时 myAmount = 0
- commission_tiers_json 为空时历史数据fallback 到 `currentSeriesAllocation.OneTimeCommissionAmount`
- [x] 4.2 修改 `matchOneTimeCommissionTier`:接受 agentTiers `[]AllocationCommissionTier` 参数作为金额来源;根据 `tier.Operator` 选择对应比较逻辑(>、>=、<、<=Operator 为空时默认 `>=`
- [x] 4.3 运行 `go build ./...` 确认无编译错误
## 5. 修复强充层级
- [x] 5.1 `internal/service/order/service.go` `checkForceRechargeRequirement()`
- `config.TriggerType == "first_recharge"` 时:直接返回需要强充(不变),不查代理配置
- `config.TriggerType == "accumulated_recharge"``config.EnableForceRecharge == false` 时:
`result.Card.ShopID`(或 `result.Device.ShopID`)查询该代理的 `ShopSeriesAllocation`
若该分配 `EnableForceRecharge=true` 则返回代理强充配置,查询不到时降级返回 `need_force_recharge=false`
- [x] 5.2 验证 `GetPurchaseCheck` 调用路径已覆盖新逻辑(复用同一函数,无需额外修改)
- [x] 5.3 运行 `go build ./...` 确认无编译错误
## 6. 新系列授权 DTO
- [x] 6.1 创建 `internal/model/dto/shop_series_grant_dto.go`,定义:
- `GrantPackageItem`package_id、cost_price、remove *bool
- `ShopSeriesGrantPackageItem`package_id、package_name、package_code、cost_price、shelf_status、status
- `GrantCommissionTierItem`operator string、threshold int64、amount int64
—— operator 仅出现在响应中(从 PackageSeries 合并),请求中不传 operator
- [x] 6.2 定义 `ShopSeriesGrantResponse`
- id、shop_id/name、series_id/name/code、commission_type
- one_time_commission_amount固定模式有效梯度模式返回 0
- commission_tiers []GrantCommissionTierItem梯度模式有值固定模式为空
- force_recharge_locked、force_recharge_enabled、force_recharge_amount
- allocator_shop_id/name、status、packages、created_at、updated_at
- [x] 6.3 定义 `CreateShopSeriesGrantRequest`
- shop_id、series_id
- one_time_commission_amount *int64固定模式必填
- commission_tiers []GrantCommissionTierItem梯度模式必填
- enable_force_recharge *bool、force_recharge_amount *int64
- packages []GrantPackageItem
- [x] 6.4 定义 `UpdateShopSeriesGrantRequest`
- one_time_commission_amount *int64
- commission_tiers []GrantCommissionTierItem
- enable_force_recharge *bool、force_recharge_amount *int64
- [x] 6.5 定义 `ManageGrantPackagesRequest`packages []GrantPackageItem
- [x] 6.6 定义 `ShopSeriesGrantListRequest`page、page_size、shop_id *uint、series_id *uint、allocator_shop_id *uint、status *int及列表 DTO`ShopSeriesGrantListItem` 含 package_count、`ShopSeriesGrantPageResult`
## 7. 新系列授权 Service
- [x] 7.1 创建 `internal/service/shop_series_grant/service.go`,定义 Service 结构及 New() 构造函数
依赖db、shopSeriesAllocationStore、shopPackageAllocationStore、shopPackageAllocationPriceHistoryStore、shopStore、packageStore、packageSeriesStore
- [x] 7.2 实现私有方法 `getParentCeilingFixed()`:固定模式天花板查询
- allocatorShopID=0 → 读 PackageSeries.commission_amount
- allocatorShopID>0 → 读分配者自身的 ShopSeriesAllocation.one_time_commission_amount
- [x] 7.3 实现私有方法 `getParentCeilingTiered()`:梯度模式天花板查询
- allocatorShopID=0 → 读 PackageSeries.config.Tiers 中各 threshold 的 amount
- allocatorShopID>0 → 读分配者自身 ShopSeriesAllocation.commission_tiers_json
- [x] 7.4 实现 `Create()`
- 查询 PackageSeries 确认 commission_type
- 检查重复授权shop_id + series_id 已有 active 记录 → 错误"该代理已存在此系列授权"
- allocator 是代理时:查分配者自身的 ShopSeriesAllocation无记录 → 错误"当前账号无此系列授权,无法向下分配"
- 固定模式one_time_commission_amount 必填 + 天花板校验
- 梯度模式commission_tiers 必填 + 阶梯数量和 threshold 必须与 PackageSeries 完全一致不多不少amount 可为 0+ 每档位天花板校验
- 强充层级判断TriggerType=first_recharge 或平台已设强充 → locked=true 忽略代理传入;仅 accumulated_recharge 且平台未设时接受代理强充配置)
- 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
- 返回聚合响应
- [x] 7.5 实现 `Get()`:查询 ShopSeriesAllocation → 查 PackageSeries 取全局 tiers含 operator→ 关联套餐分配 → 拼装 ShopSeriesGrantResponse
梯度模式下commission_tiers 响应需将 agent 的 amount 与 PackageSeries tiers 的 operator 按 threshold 合并)
- [x] 7.6 实现 `List()`:分页查询 → 统计 package_count → 返回 ShopSeriesGrantPageResult
- [x] 7.7 实现 `Update()`
- 固定模式:含 one_time_commission_amount 时做天花板校验
- 梯度模式:含 commission_tiers 时做每档位天花板校验
- 平台已设强充时忽略强充变更
- 保存更新
- [x] 7.8 实现 `ManagePackages()`:事务中处理 packages 列表:
- remove=true查找 active 的 ShopPackageAllocation找到则软删除找不到则静默忽略
- 无 remove 标志:校验套餐归属和分配权限,查现有 active 记录(有则更新 cost_price+写历史,无则新建)
- [x] 7.9 实现 `Delete()`:检查子级依赖 → 事务软删除 ShopSeriesAllocation + 所有关联 ShopPackageAllocation
- [x] 7.10 运行 `go build ./...` 确认无编译错误
## 8. Handler、路由及文档生成器
- [x] 8.1 创建 `internal/handler/admin/shop_series_grant.go`,实现 Create、Get、List、Update、ManagePackages、Delete 六个 Handler 方法
- [x] 8.2 创建 `internal/routes/shop_series_grant.go`注册路由Tag: "代理系列授权"
- `GET /shop-series-grants`
- `POST /shop-series-grants`
- `GET /shop-series-grants/:id`
- `PUT /shop-series-grants/:id`
- `DELETE /shop-series-grants/:id`
- `PUT /shop-series-grants/:id/packages`
- [x] 8.3 运行 `go build ./...` 确认无编译错误
## 9. 依赖注入 & Bootstrap
- [x] 9.1 `internal/bootstrap/types.go`:添加 `ShopSeriesGrant *admin.ShopSeriesGrantHandler` 字段
- [x] 9.2 `internal/bootstrap/services.go`import shop_series_grant service 包,添加字段并在 `initServices()` 中初始化
- [x] 9.3 `internal/bootstrap/handlers.go`:添加 ShopSeriesGrant Handler 初始化
- [x] 9.4 `pkg/openapi/handlers.go`:添加 `ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil)`
- [x] 9.5 `internal/routes/admin.go`:添加 `registerShopSeriesGrantRoutes()` 调用
- [x] 9.6 运行 `go build ./...` 确认完整构建通过
## 10. 执行迁移 & 数据验证
- [x] 10.1 执行迁移(`make migrate-up`),用 db-migration 规范验证3 列已删除commission_tiers_json 列已添加
- [x] 10.2 db-validation创建固定模式授权正常路径确认 ShopSeriesAllocation 和 ShopPackageAllocation 均创建成功
- [x] 10.3 db-validation固定模式金额超过父级天花板时接口返回错误
- [x] 10.4 db-validation梯度模式创建授权commission_tiers_json 正确写入
- [x] 10.5 db-validation梯度模式某档位金额超过父级同档位时接口返回错误
- [x] 10.6 db-validation代理自设强充后购买预检接口返回 need_force_recharge=true金额与代理设置一致
- [x] 10.7 db-validation平台系列已设强充时代理自设强充被锁定购买预检使用平台强充金额
- [x] 10.8 db-validation梯度模式阶梯含 `operator="<"` 时,销售统计低于阈值的代理命中该档位,高于阈值的代理不命中