fix: 修复梯度佣金档位字段缺失,补全授权接口响应字段及强充有效状态
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m27s

- OneTimeCommissionTierDTO 补充 operator 字段映射
- GrantCommissionTierItem 补充 dimension/stat_scope 字段(从全局配置合并)
- 系列授权列表/详情补充强充锁定状态和强充金额的有效值计算
- 同步 OpenSpec 主规范并归档变更文档
This commit is contained in:
2026-03-05 11:23:28 +08:00
parent de9eacd273
commit b52cb9a078
12 changed files with 451 additions and 27 deletions

View File

@@ -4,6 +4,7 @@ package dto
type OneTimeCommissionTierDTO struct { type OneTimeCommissionTierDTO struct {
Dimension string `json:"dimension" validate:"required,oneof=sales_count sales_amount" required:"true" description:"统计维度 (sales_count:销量, sales_amount:销售额)"` Dimension string `json:"dimension" validate:"required,oneof=sales_count sales_amount" required:"true" description:"统计维度 (sales_count:销量, sales_amount:销售额)"`
StatScope string `json:"stat_scope" validate:"omitempty,oneof=self self_and_sub" description:"统计范围 (self:仅自己, self_and_sub:自己+下级)"` StatScope string `json:"stat_scope" validate:"omitempty,oneof=self self_and_sub" description:"统计范围 (self:仅自己, self_and_sub:自己+下级)"`
Operator string `json:"operator,omitempty" validate:"omitempty,oneof=> >= < <=" description:"阈值比较运算符(>、>=、<、<=),空值时计算引擎默认 >="`
Threshold int64 `json:"threshold" validate:"required,min=0" required:"true" minimum:"0" description:"达标阈值"` Threshold int64 `json:"threshold" validate:"required,min=0" required:"true" minimum:"0" description:"达标阈值"`
Amount int64 `json:"amount" validate:"required,min=0" required:"true" minimum:"0" description:"佣金金额(分)"` Amount int64 `json:"amount" validate:"required,min=0" required:"true" minimum:"0" description:"佣金金额(分)"`
} }

View File

@@ -17,9 +17,11 @@ type ShopSeriesGrantPackageItem struct {
Status int `json:"status" description:"分配状态 1-启用 2-禁用"` Status int `json:"status" description:"分配状态 1-启用 2-禁用"`
} }
// GrantCommissionTierItem 梯度佣金档位operator 仅出现在响应中,来自 PackageSeries 全局配置) // GrantCommissionTierItem 梯度佣金档位operator/dimension/stat_scope 仅出现在响应中,来自 PackageSeries 全局配置)
type GrantCommissionTierItem struct { type GrantCommissionTierItem struct {
Operator string `json:"operator,omitempty" description:"比较运算符(>、>=、<、<=),响应中从 PackageSeries 合并,请求中不传"` Operator string `json:"operator,omitempty" description:"比较运算符(>、>=、<、<=),响应中从 PackageSeries 合并,请求中不传"`
Dimension string `json:"dimension,omitempty" description:"统计维度sales_count:销售量, sales_amount:销售额),来自 PackageSeries 全局配置,响应中只读"`
StatScope string `json:"stat_scope,omitempty" description:"统计范围self:仅自己, self_and_sub:自己+下级),来自 PackageSeries 全局配置,响应中只读"`
Threshold int64 `json:"threshold" description:"阈值(与 PackageSeries 全局配置对应)"` Threshold int64 `json:"threshold" description:"阈值(与 PackageSeries 全局配置对应)"`
Amount int64 `json:"amount" description:"该代理在此档位的佣金金额(分)"` Amount int64 `json:"amount" description:"该代理在此档位的佣金金额(分)"`
} }
@@ -90,6 +92,8 @@ type ShopSeriesGrantListItem struct {
CommissionType string `json:"commission_type" description:"佣金类型"` CommissionType string `json:"commission_type" description:"佣金类型"`
OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"固定模式佣金金额(分)"` OneTimeCommissionAmount int64 `json:"one_time_commission_amount" description:"固定模式佣金金额(分)"`
ForceRechargeEnabled bool `json:"force_recharge_enabled" description:"是否启用强充"` ForceRechargeEnabled bool `json:"force_recharge_enabled" description:"是否启用强充"`
ForceRechargeLocked bool `json:"force_recharge_locked" description:"强充是否被套餐系列锁定true 时代理不可修改)"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"`
AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"` AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"`
AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"`
PackageCount int `json:"package_count" description:"已授权套餐数量"` PackageCount int `json:"package_count" description:"已授权套餐数量"`

View File

@@ -263,6 +263,7 @@ func (s *Service) dtoToModelConfig(dtoConfig *dto.SeriesOneTimeCommissionConfigD
tiers = make([]model.OneTimeCommissionTier, len(dtoConfig.Tiers)) tiers = make([]model.OneTimeCommissionTier, len(dtoConfig.Tiers))
for i, tier := range dtoConfig.Tiers { for i, tier := range dtoConfig.Tiers {
tiers[i] = model.OneTimeCommissionTier{ tiers[i] = model.OneTimeCommissionTier{
Operator: tier.Operator,
Dimension: tier.Dimension, Dimension: tier.Dimension,
StatScope: tier.StatScope, StatScope: tier.StatScope,
Threshold: tier.Threshold, Threshold: tier.Threshold,
@@ -296,6 +297,7 @@ func (s *Service) modelToDTO(config *model.OneTimeCommissionConfig) *dto.SeriesO
tiers = make([]dto.OneTimeCommissionTierDTO, len(config.Tiers)) tiers = make([]dto.OneTimeCommissionTierDTO, len(config.Tiers))
for i, tier := range config.Tiers { for i, tier := range config.Tiers {
tiers[i] = dto.OneTimeCommissionTierDTO{ tiers[i] = dto.OneTimeCommissionTierDTO{
Operator: tier.Operator,
Dimension: tier.Dimension, Dimension: tier.Dimension,
StatScope: tier.StatScope, StatScope: tier.StatScope,
Threshold: tier.Threshold, Threshold: tier.Threshold,

View File

@@ -128,11 +128,17 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
resp.AllocatorShopName = "平台" resp.AllocatorShopName = "平台"
} }
// 强充状态first_recharge 或平台已启用 accumulated_recharge 强充时,锁定不可改 // 强充有效状态first_recharge 或平台已启用 accumulated_recharge 强充时,锁定不可改
forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge
resp.ForceRechargeLocked = forceRechargeLocked resp.ForceRechargeLocked = forceRechargeLocked
resp.ForceRechargeEnabled = allocation.EnableForceRecharge if forceRechargeLocked {
resp.ForceRechargeAmount = allocation.ForceRechargeAmount // 锁定时强充实际生效金额取套餐系列配置值allocation 字段为 0 不能使用)
resp.ForceRechargeEnabled = true
resp.ForceRechargeAmount = config.ForceAmount
} else {
resp.ForceRechargeEnabled = allocation.EnableForceRecharge
resp.ForceRechargeAmount = allocation.ForceRechargeAmount
}
// 固定模式 // 固定模式
if config.CommissionType == "fixed" { if config.CommissionType == "fixed" {
@@ -153,11 +159,13 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
// 合并全局 operator 和代理 amount // 合并全局 operator 和代理 amount
tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers)) tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers))
for _, globalTier := range config.Tiers { for _, globalTier := range config.Tiers {
tiers = append(tiers, dto.GrantCommissionTierItem{ tiers = append(tiers, dto.GrantCommissionTierItem{
Operator: globalTier.Operator, Operator: globalTier.Operator,
Threshold: globalTier.Threshold, Dimension: globalTier.Dimension,
Amount: agentAmountMap[globalTier.Threshold], StatScope: globalTier.StatScope,
}) Threshold: globalTier.Threshold,
Amount: agentAmountMap[globalTier.Threshold],
})
} }
resp.CommissionTiers = tiers resp.CommissionTiers = tiers
} }
@@ -452,9 +460,11 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest)
SeriesID: a.SeriesID, SeriesID: a.SeriesID,
AllocatorShopID: a.AllocatorShopID, AllocatorShopID: a.AllocatorShopID,
OneTimeCommissionAmount: a.OneTimeCommissionAmount, OneTimeCommissionAmount: a.OneTimeCommissionAmount,
ForceRechargeEnabled: a.EnableForceRecharge, // 强充有效状态在 seriesMap 分支中计算,此处先设默认值
Status: a.Status, ForceRechargeEnabled: a.EnableForceRecharge,
CreatedAt: a.CreatedAt.Format(time.DateTime), ForceRechargeAmount: a.ForceRechargeAmount,
Status: a.Status,
CreatedAt: a.CreatedAt.Format(time.DateTime),
} }
if a.AllocatorShopID > 0 { if a.AllocatorShopID > 0 {
item.AllocatorShopName = shopMap[a.AllocatorShopID] item.AllocatorShopName = shopMap[a.AllocatorShopID]
@@ -466,6 +476,16 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest)
config, _ := sr.GetOneTimeCommissionConfig() config, _ := sr.GetOneTimeCommissionConfig()
if config != nil { if config != nil {
item.CommissionType = config.CommissionType item.CommissionType = config.CommissionType
// 计算强充有效状态first_recharge 或平台已启用 accumulated_recharge 强充时锁定
forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge
item.ForceRechargeLocked = forceRechargeLocked
if forceRechargeLocked {
item.ForceRechargeEnabled = true
item.ForceRechargeAmount = config.ForceAmount
} else {
item.ForceRechargeEnabled = a.EnableForceRecharge
item.ForceRechargeAmount = a.ForceRechargeAmount
}
} }
} }
// 统计套餐数量 // 统计套餐数量

View File

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

View File

@@ -0,0 +1,66 @@
## Context
`refactor-agent-series-grant` 变更在 Model 层(`model.OneTimeCommissionTier`)已正确新增 `Operator` 字段,并添加了运算符常量(`TierOperatorGT/GTE/LT/LTE`),但遗漏了同步更新 DTO 层和 Service 层的映射逻辑。
具体遗漏:
1. `OneTimeCommissionTierDTO`package_series_dto.go未加 `Operator` 字段 → API 无法读写 operator
2. `GrantCommissionTierItem`shop_series_grant_dto.go未加 `Dimension``StatScope` 字段 → grant 响应中梯度档位条件不透明
3. `package_series/service.go``dtoToModelConfig()` / `modelToDTO()` 未处理 `Operator` 映射
4. `shop_series_grant/service.go``buildGrantResponse()` 合并全局 tiers 时未携带 `Dimension``StatScope`
5. 系列授权列表/详情响应未正确反映 `force_recharge_enabled` 有效状态,且列表缺少 `force_recharge_locked``force_recharge_amount` 字段
无数据库结构变更,纯 DTO + Service 层修复。
**Bug 3 根因详述**`forceRechargeLocked = config.TriggerType == FirstRecharge || config.EnableForceRecharge`。当此条件为 `true`Service 正确跳过写 `allocation.EnableForceRecharge=true`,分配表中该字段保持 `false`。但 List 只返回 `a.EnableForceRecharge`Detail `buildGrantResponse` 也返回 `allocation.EnableForceRecharge`,两处均未计算有效状态,导致前端看到 `force_recharge_enabled=false`
## Goals / Non-Goals
**Goals:**
- `OneTimeCommissionTierDTO` 支持 `operator` 字段的读写(创建/更新套餐系列时可传,查询时返回)
- `GrantCommissionTierItem` 响应中补充展示 `dimension``stat_scope`(只读,从 PackageSeries 全局配置合并)
- 向前兼容:`operator` 字段均使用 `omitempty`,老客户端请求不传时默认 `>=`(沿用 Model 层现有 fallback 逻辑)
- `ShopSeriesGrantListItem` 补充 `force_recharge_locked``force_recharge_amount` 字段
- 列表和详情 `force_recharge_enabled` 反映有效状态(`allocation.EnableForceRecharge || forceRechargeLocked`);锁定时 `force_recharge_amount``config.ForceAmount`
**Non-Goals:**
- 不修改数据库结构、迁移文件
- 不修改 `model.OneTimeCommissionTier`(已正确)
- 不修改佣金计算引擎(`commission_calculation/service.go`
- 不修改 `GrantCommissionTierItem` 请求侧(代理创建授权时仍不传 operator/dimension/stat_scope这三个字段来自全局配置不可修改
- 不修改梯度档位的校验逻辑threshold 匹配等已正确)
## Decisions
### 决策 1`Operator` 在 DTO 中用 `omitempty`
`OneTimeCommissionTierDTO.Operator``GrantCommissionTierItem.Operator` 均使用 `json:"operator,omitempty"`
**理由**:现有未设 operator 的套餐系列JSONB 中该字段为空字符串,`omitempty` 避免返回无意义的 `""` 给前端,且与 Model 层的"Operator 为空时 fallback 到 `>=`"语义一致。
**备选**:永远返回字符串(空或 `>=`)→ 否决,因为"空"在 JSON 中会被序列化为 `""`,语义不清晰,而写入 JSONB 时可能覆盖原本为空的历史数据。
### 决策 2`Dimension`/`StatScope` 仅出现在响应中,请求侧不开放
`GrantCommissionTierItem``Dimension``StatScope` 仅用于 GET/POST/PUT 的响应输出(从 PackageSeries 全局配置按 threshold 合并),请求体中代理仍只传 `threshold``amount`
**理由**:这两个字段是套餐系列级别的全局配置,代理在创建授权时不能修改条件,只能修改金额。与 `Operator` 的处理方式保持一致(已在上次变更中确立该设计原则)。
### 决策 3`buildGrantResponse` 按 threshold 索引合并全部条件字段
现有代码按 threshold 构建 `agentAmountMap`,在遍历 `config.Tiers` 时合并 `Operator`。本次同步合并 `Dimension``StatScope`无需额外查询O(N) 时间复杂度N 为档位数(通常 ≤ 5性能无影响。
### 决策 4`force_recharge_enabled` 返回有效状态而非存储状态
列表和详情响应中,`force_recharge_enabled = allocation.EnableForceRecharge || forceRechargeLocked`
**理由**:前端关心的是「强充是否实际生效」而非「代理是否主动开启」。当系列锁定强充时,即使 allocation 存储 `false`Service 正确设计:锁定时不覆盖),对用户而言强充仍然生效。返回 `false` 会让前端误判,需在响应层修正。
**备选**:返回存储值(`allocation.EnableForceRecharge`)并依靠 `force_recharge_locked` 由前端推导 → 否决,因为历史已有前端对接问题,且两个字段冗余更易出错。统一在后端计算有效状态是更稳健的 API 设计。
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|----------|
| 历史套餐系列 JSONB 中梯度档位无 `operator`/`dimension`/`stat_scope` | 响应用 `omitempty`,缺失字段不出现在响应中而非返回空值;前端应做好字段缺失的降级处理 |
| `CreateShopSeriesGrantRequest.CommissionTiers` 中含有 `operator`/`dimension`/`stat_scope` 字段(因共用同一 DTO 结构) | `GrantCommissionTierItem` 在请求侧这三个字段会被忽略Service 层不读取),无副作用;可在 description 注释中说明仅响应有效 |
| 锁定时 `force_recharge_amount` 来源变更config 而非 allocation | 列表新增字段详情原有字段调整逻辑前端只需使用响应值无需区分来源allocation 表 `force_recharge_amount` 在锁定时仍存 0不需迁移 |

View File

@@ -0,0 +1,47 @@
## Why
`refactor-agent-series-grant` 变更遗留了两处实现漏洞,加上前端对接时发现的第三处 Bug共三个问题
1. 套餐系列管理 API 的梯度档位 DTO 缺少 `operator` 字段,导致无法通过接口设置/查看比较运算符。
2. 代理系列授权的梯度响应缺少 `dimension`(销售量/销售额)和 `stat_scope`(统计范围)字段,前端完全无法理解阈值的业务含义。
3. 系列授权列表和详情响应中,当套餐系列配置锁定了强充(`enable_force_recharge=true``trigger_type=first_recharge`)时,`force_recharge_enabled` 仍返回 `false`(分配记录自身的值),未反映有效状态;列表还缺少 `force_recharge_locked``force_recharge_amount` 字段。
## What Changes
- **修复** `OneTimeCommissionTierDTO``package_series_dto.go`):新增 `Operator string` 字段,支持创建/更新套餐系列时传入并保存梯度阶梯的比较运算符(`>``>=``<``<=`
- **修复** `package_series/service.go``dtoToModelConfig()` 新增 `Operator` 字段映射;`modelToDTO()` 新增 `Operator` 字段回填,使 `PackageSeriesResponse` 能正确返回 `operator`
- **修复** `GrantCommissionTierItem``shop_series_grant_dto.go`):新增 `Dimension string``StatScope string` 字段
- **修复** `shop_series_grant/service.go``buildGrantResponse()` 合并全局 PackageSeries tiers 时,除 `Operator` 外同步合并 `Dimension``StatScope`
- **修复** `ShopSeriesGrantListItem``shop_series_grant_dto.go`):新增 `ForceRechargeLocked bool``ForceRechargeAmount int64` 字段
- **修复** `shop_series_grant/service.go`:列表构建时计算 `forceRechargeLocked`,当锁定时 `ForceRechargeEnabled=true``ForceRechargeAmount=config.ForceAmount`;详情 `buildGrantResponse()` 同步修正 `ForceRechargeEnabled``ForceRechargeAmount` 有效状态逻辑
## Capabilities
### New Capabilities
(无新增 Capability
### Modified Capabilities
- `package-series-management`:梯度档位配置(`one_time_commission_config.tiers`)支持通过 API 读写 `operator` 字段(创建时传入、查询时返回)
- `agent-series-grant``commission_tiers` 响应中补充展示 `dimension``sales_count` / `sales_amount`)和 `stat_scope``self` / `self_and_sub`),这两个字段来自 PackageSeries 全局配置,对代理只读
- `agent-series-grant`:系列授权列表(`GET /api/admin/shop-series-grants`)新增 `force_recharge_locked``force_recharge_amount` 字段;列表和详情中 `force_recharge_enabled` 反映有效状态(锁定时为 `true`
## Impact
**受影响的代码**
- `internal/model/dto/package_series_dto.go``OneTimeCommissionTierDTO` 新增 `Operator` 字段
- `internal/service/package_series/service.go``dtoToModelConfig()``modelToDTO()` 处理 `Operator`
- `internal/model/dto/shop_series_grant_dto.go``GrantCommissionTierItem` 新增 `Dimension``StatScope` 字段
- `internal/service/shop_series_grant/service.go``buildGrantResponse()` 合并 `Dimension``StatScope`
- `internal/model/dto/shop_series_grant_dto.go``ShopSeriesGrantListItem` 新增 `ForceRechargeLocked``ForceRechargeAmount` 字段
**受影响的 API**
- `POST/PUT /api/admin/package-series`:请求体中梯度档位可传 `operator`;响应中梯度档位包含 `operator`
- `GET /api/admin/package-series/:id`:同上
- `GET /api/admin/shop-series-grants/:id`:响应中 `commission_tiers` 新增 `dimension``stat_scope` 字段
- `POST /api/admin/shop-series-grants`同上Create 响应)
- `PUT /api/admin/shop-series-grants/:id`同上Update 响应)
- `GET /api/admin/shop-series-grants`(列表):新增 `force_recharge_locked``force_recharge_amount` 字段;`force_recharge_enabled` 反映有效状态
**无数据库迁移**:仅涉及 DTO 和 Service 层代码,不改动数据库结构和 Model 层(`OneTimeCommissionTier` model 已在上次变更中添加 `Operator` 字段;`tb_shop_series_allocation` 表结构已有 `enable_force_recharge`/`force_recharge_amount` 字段,无需迁移)

View File

@@ -0,0 +1,109 @@
## MODIFIED Requirements
### Requirement: 查询系列授权详情
系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。梯度模式下,`commission_tiers` 中每个档位 MUST 包含 `dimension`(统计维度)和 `stat_scope`(统计范围)字段,这两个字段从 PackageSeries 全局配置按 `threshold` 合并,对代理只读。
**变更说明**`GrantCommissionTierItem` 新增 `dimension``stat_scope` 字段,`buildGrantResponse()` 在合并 `operator` 的同时同步合并这两个字段。
#### Scenario: 固定模式详情
- **WHEN** 查询固定模式系列授权详情
- **THEN** 响应包含 `commission_type="fixed"``one_time_commission_amount=有效值``commission_tiers=[]`
#### Scenario: 梯度模式详情
- **WHEN** 查询梯度模式系列授权详情
- **THEN** 响应包含 `commission_type="tiered"``one_time_commission_amount=0`
- **AND** `commission_tiers` 中每个档位包含 `operator``dimension``stat_scope``threshold``amount` 五个字段
- **AND** `operator``dimension``stat_scope` 的值来自 PackageSeries 全局配置(对应 threshold 的档位),代理的 `amount` 来自 `ShopSeriesAllocation.commission_tiers_json`
#### Scenario: 梯度模式 dimension 为销售量
- **WHEN** 查询梯度模式授权详情PackageSeries 阶梯 `dimension = "sales_count"`
- **THEN** 响应中对应档位 `dimension = "sales_count"`,前端展示"销售量"条件
#### Scenario: 梯度模式 dimension 为销售额
- **WHEN** 查询梯度模式授权详情PackageSeries 阶梯 `dimension = "sales_amount"`
- **THEN** 响应中对应档位 `dimension = "sales_amount"`,前端展示"销售额"条件
#### Scenario: 梯度模式 stat_scope 区分
- **WHEN** 查询梯度模式授权详情
- **THEN** 响应中 `stat_scope` 正确反映 PackageSeries 配置的统计范围(`"self"``"self_and_sub"`
#### Scenario: 查询不存在的授权
- **WHEN** 查询不存在的授权 ID
- **THEN** 系统返回错误"授权记录不存在"
---
### Requirement: 创建系列授权(梯度模式)
系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0不可省略该档位。创建成功后的响应中`commission_tiers` 每个档位 MUST 包含 `operator``dimension``stat_scope` 字段(从全局配置合并)。
**变更说明**Create 响应复用同一 `buildGrantResponse()`,故创建响应也自动包含 `dimension`/`stat_scope`
#### 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}]`
- **AND** 响应中 `commission_tiers=[{operator:">=", dimension:"sales_count", stat_scope:"self", threshold:100, amount:50}, ...]`
#### Scenario: 平台成功创建梯度模式授权
- **WHEN** 平台为顶级代理A 创建授权PackageSeries 阶梯含 `operator`/`dimension`/`stat_scope`
- **THEN** 系统创建授权,响应中 `commission_tiers` 包含 PackageSeries 全局 `operator``dimension``stat_scope`
#### Scenario: 梯度模式某档位金额超过父级
- **WHEN** 代理A 的阶梯第一档 `amount=80`A 为 B 创建授权时传入第一档 `amount=90`
- **THEN** 系统返回错误"某档位佣金金额超过上级天花板"
#### Scenario: 梯度模式传入了不存在的阈值
- **WHEN** PackageSeries 只有 `threshold=100``150` 两档,请求中传入 `threshold=200`
- **THEN** 系统返回错误"梯度阶梯 threshold 与系列配置不匹配"
#### Scenario: 梯度模式 commission_tiers 为必填
- **WHEN** 请求中不包含 `commission_tiers` 或为空数组
- **THEN** 系统返回参数错误"梯度模式必须填写阶梯配置"
---
### Requirement: 系列授权列表强充状态正确反映
系列授权列表 (`GET /shop-series-grants`) MUST 在每个列表项中返回 `force_recharge_locked`(是否被套餐系列锁定)和 `force_recharge_amount`(强充金额)。`force_recharge_enabled` MUST 反映有效状态:当锁定时为 `true`(无论分配记录自身如何);未锁定时取分配记录的实际设置。
**变更说明**`ShopSeriesGrantListItem` 新增 `force_recharge_locked bool``force_recharge_amount int64`。列表构建时从套餐系列配置计算有效强充状态。
#### Scenario: 套餐系列锁定强充
- **WHEN** 套餐系列配置 `enable_force_recharge=true``trigger_type=first_recharge`,查询列表
- **THEN** 列表项中 `force_recharge_locked=true``force_recharge_enabled=true``force_recharge_amount`=系列配置的 `force_amount`
#### Scenario: 代理自身开启强充(未锁定)
- **WHEN** 套餐系列未锁定强充,分配记录中 `enable_force_recharge=true`,查询列表
- **THEN** 列表项中 `force_recharge_locked=false``force_recharge_enabled=true``force_recharge_amount`=分配记录的实际金额
#### Scenario: 代理未开启强充(未锁定)
- **WHEN** 套餐系列未锁定强充,分配记录中 `enable_force_recharge=false`,查询列表
- **THEN** 列表项中 `force_recharge_locked=false``force_recharge_enabled=false``force_recharge_amount=0`
---
### Requirement: 查询系列授权详情强充有效状态
系列授权详情 (`GET /shop-series-grants/:id`) 中,`force_recharge_enabled` MUST 反映有效状态:当锁定时为 `true``force_recharge_amount` 锁定时应返回系列配置的 `force_amount`
**变更说明**`buildGrantResponse()` 修正强充字段有效状态计算逻辑。
#### Scenario: 锁定强充时详情响应
- **WHEN** 套餐系列锁定强充,查询对应分配记录详情
- **THEN** `force_recharge_locked=true``force_recharge_enabled=true``force_recharge_amount`=系列配置的 `force_amount`

View File

@@ -0,0 +1,38 @@
## MODIFIED Requirements
### Requirement: 套餐系列一次性佣金规则配置
系统 SHALL 在套餐系列层面配置一次性佣金的完整规则,包括触发条件、阈值、金额/梯度、时效、强充配置。梯度配置(`commission_type=tiered`)中每个档位 MUST 支持通过 `operator` 字段设置阈值比较运算符(`>``>=``<``<=`),默认值为 `>=`
**变更说明**:梯度档位 `OneTimeCommissionTierDTO` 新增 `operator` 字段,创建/更新套餐系列时可传入并持久化,查询时返回。
#### Scenario: 配置首充规则
- **WHEN** 创建或更新套餐系列
- **AND** 设置一次性佣金规则:`trigger_type = first_recharge``threshold = 10000`100元`commission_amount = 2000`20元
- **THEN** 系统保存该规则配置
#### Scenario: 配置累计充值规则
- **WHEN** 创建或更新套餐系列
- **AND** 设置一次性佣金规则:`trigger_type = accumulated_recharge``threshold = 20000`200元`commission_amount = 4000`40元
- **THEN** 系统保存该规则配置
#### Scenario: 配置梯度规则(含 operator
- **WHEN** 创建或更新套餐系列,`commission_type = tiered`
- **AND** 梯度配置包含 `operator` 字段:`[{operator: ">=", dimension: "sales_count", stat_scope: "self", threshold: 100, amount: 1000}, {operator: "<", dimension: "sales_count", stat_scope: "self", threshold: 50, amount: 500}]`
- **THEN** 系统保存完整梯度配置(含 operator
- **AND** 查询详情时响应中 `tiers` 包含 `operator` 字段
#### Scenario: 配置梯度规则(不传 operator向后兼容
- **WHEN** 创建或更新套餐系列,`commission_type = tiered`
- **AND** 梯度配置未提供 `operator` 字段:`[{dimension: "sales_count", stat_scope: "self", threshold: 100, amount: 1000}]`
- **THEN** 系统保存梯度配置,`operator` 存储为空值(计算引擎 fallback 到 `>=`
- **AND** 查询详情时响应中 `tiers``operator` 字段不出现omitempty
#### Scenario: 查询系列详情包含规则
- **WHEN** 查询套餐系列详情
- **THEN** 返回完整的一次性佣金规则配置,梯度档位包含 `operator``dimension``stat_scope``threshold``amount`

View File

@@ -0,0 +1,55 @@
## 1. 套餐系列 DTO 修复Operator 字段)
- [x] 1.1 `internal/model/dto/package_series_dto.go``OneTimeCommissionTierDTO` 新增 `Operator string` 字段tag 为 `json:"operator,omitempty" validate:"omitempty,oneof=> >= < <=" description:"阈值比较运算符(>、>=、<、<=),空值时计算引擎默认 >="`
- [x] 1.2 `internal/service/package_series/service.go``dtoToModelConfig()``OneTimeCommissionTier` 赋值新增 `Operator: tier.Operator`
- [x] 1.3 `internal/service/package_series/service.go``modelToDTO()``OneTimeCommissionTierDTO` 赋值新增 `Operator: tier.Operator`
- [x] 1.4 运行 `go build ./...` 确认无编译错误
## 2. 授权分配 DTO 修复Dimension / StatScope 字段)
- [x] 2.1 `internal/model/dto/shop_series_grant_dto.go``GrantCommissionTierItem` 新增两个字段:
- `Dimension string`tag 为 `json:"dimension,omitempty" description:"统计维度sales_count:销售量, sales_amount:销售额),来自 PackageSeries 全局配置,响应中只读"`
- `StatScope string`tag 为 `json:"stat_scope,omitempty" description:"统计范围self:仅自己, self_and_sub:自己+下级),来自 PackageSeries 全局配置,响应中只读"`
- [x] 2.2 `internal/service/shop_series_grant/service.go``buildGrantResponse()` 梯度模式合并分支,在构造 `GrantCommissionTierItem` 时补充 `Dimension: globalTier.Dimension``StatScope: globalTier.StatScope`
- [x] 2.3 运行 `go build ./...` 确认无编译错误
## 3. 验证
- [x] 3.1 db-validation创建含 `operator`/`dimension`/`stat_scope` 的梯度套餐系列,查询详情确认响应中 tiers 包含完整字段
- [x] 3.2 db-validation创建梯度模式系列授权调用 `GET /shop-series-grants/:id`,确认 `commission_tiers` 中每档位包含 `operator``dimension``stat_scope``threshold``amount`
- [x] 3.3 db-validation调用 `POST /shop-series-grants`Create`PUT /shop-series-grants/:id`Update确认响应中同样携带 `dimension`/`stat_scope`
- [x] 3.4 db-validation不传 `operator` 的梯度档位,确认响应中 `operator` 字段缺失omitempty 生效),不影响佣金计算逻辑
## 4. 强充状态有效展示修复
- [x] 4.1 `internal/model/dto/shop_series_grant_dto.go``ShopSeriesGrantListItem` 新增两个字段:
- `ForceRechargeLocked bool`tag 为 `json:"force_recharge_locked" description:"强充是否被套餐系列锁定true 时代理不可修改)"`
- `ForceRechargeAmount int64`tag 为 `json:"force_recharge_amount" description:"强充金额(分)"`
- [x] 4.2 `internal/service/shop_series_grant/service.go`列表构建for 循环内的 `if sr, ok := seriesMap[a.SeriesID]` 分支)修正强充状态字段:
```go
forceRechargeLocked := config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge || config.EnableForceRecharge
item.ForceRechargeLocked = forceRechargeLocked
if forceRechargeLocked {
item.ForceRechargeEnabled = true
item.ForceRechargeAmount = config.ForceAmount
} else {
item.ForceRechargeEnabled = a.EnableForceRecharge
item.ForceRechargeAmount = a.ForceRechargeAmount
}
```
- [x] 4.3 `internal/service/shop_series_grant/service.go``buildGrantResponse()` 内强充状态计算修正(当前约 L132-L135
```go
if forceRechargeLocked {
resp.ForceRechargeEnabled = true
resp.ForceRechargeAmount = config.ForceAmount
} else {
resp.ForceRechargeEnabled = allocation.EnableForceRecharge
resp.ForceRechargeAmount = allocation.ForceRechargeAmount
}
```
- [x] 4.4 运行 `go build ./...` 确认无编译错误
## 5. 强充 Bug 验证
- [x] 5.1 db-validation查询套餐系列中 `enable_force_recharge=true` 的系列(如 series_id=21172118对应的授权分配列表确认 `force_recharge_locked=true`、`force_recharge_enabled=true`、`force_recharge_amount=系列配置值`
- [x] 5.2 db-validation调用 `GET /api/admin/shop-series-grants/:id`(锁定系列的分配记录),确认详情响应中 `force_recharge_locked=true`、`force_recharge_enabled=true`

View File

@@ -46,27 +46,28 @@
### Requirement: 创建系列授权(梯度模式) ### Requirement: 创建系列授权(梯度模式)
系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0不可省略该档位。 系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0不可省略该档位。创建成功后的响应中,`commission_tiers` 每个档位 MUST 包含 `operator``dimension``stat_scope` 字段(从全局配置合并)。
#### Scenario: 代理成功创建梯度模式授权 #### 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}] - **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 读取后合并) - **THEN** 系统创建授权,`commission_tiers_json` 存储 `[{threshold:100, amount:50}, {threshold:150, amount:100}]`
- **AND** 响应中 `commission_tiers=[{operator:">=" , dimension:"sales_count", stat_scope:"self", threshold:100, amount:50}, ...]`
#### Scenario: 平台成功创建梯度模式授权 #### Scenario: 平台成功创建梯度模式授权
- **WHEN** 平台为顶级代理A 创建授权PackageSeries 阶梯为 [{operator:">=", threshold:100, amount:100}, {operator:"<", threshold:50, amount:30}],传入 commission_tiers=[{threshold:100, amount:80}, {threshold:50, amount:20}] - **WHEN** 平台为顶级代理A 创建授权PackageSeries 阶梯`operator`/`dimension`/`stat_scope`
- **THEN** 系统创建授权,A 的专属阶梯存入 commission_tiers_json响应中 commission_tiers 包含对应的 operator - **THEN** 系统创建授权,响应中 `commission_tiers` 包含 PackageSeries 全局 `operator``dimension``stat_scope`
#### Scenario: 梯度模式某档位金额超过父级 #### Scenario: 梯度模式某档位金额超过父级
- **WHEN** 代理A 的阶梯第一档 amount=80A 为 B 创建授权时传入第一档 amount=90 - **WHEN** 代理A 的阶梯第一档 `amount=80`A 为 B 创建授权时传入第一档 `amount=90`
- **THEN** 系统返回错误"梯度佣金档位金额不能超过上级同档位限额" - **THEN** 系统返回错误“某档位佣金金额超过上级天花板”
#### Scenario: 梯度模式传入了不存在的阈值 #### Scenario: 梯度模式传入了不存在的阈值
- **WHEN** PackageSeries 只有 threshold=100 和 150 两档,请求中传入 threshold=200 - **WHEN** PackageSeries 只有 `threshold=100``150` 两档,请求中传入 `threshold=200`
- **THEN** 系统返回错误"阶梯阈值与系列配置不匹配" - **THEN** 系统返回错误“梯度阶梯 threshold 与系列配置不匹配
#### Scenario: 梯度模式 commission_tiers 为必填 #### Scenario: 梯度模式 commission_tiers 为必填
- **WHEN** 请求中不包含 commission_tiers 或为空数组 - **WHEN** 请求中不包含 `commission_tiers` 或为空数组
- **THEN** 系统返回参数错误"梯度模式必须提供阶梯金额配置" - **THEN** 系统返回参数错误梯度模式必须填写阶梯配置
--- ---
@@ -90,19 +91,33 @@
### Requirement: 查询系列授权详情 ### Requirement: 查询系列授权详情
系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。 系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。梯度模式下,`commission_tiers` 中每个档位 MUST 包含 `dimension`(统计维度)和 `stat_scope`(统计范围)字段,这两个字段从 PackageSeries 全局配置按 `threshold` 合并,对代理只读。
#### Scenario: 固定模式详情 #### Scenario: 固定模式详情
- **WHEN** 查询固定模式系列授权详情 - **WHEN** 查询固定模式系列授权详情
- **THEN** 响应包含 commission_type="fixed"one_time_commission_amount=有效值commission_tiers=[] - **THEN** 响应包含 `commission_type="fixed"``one_time_commission_amount=有效值``commission_tiers=[]`
#### Scenario: 梯度模式详情 #### Scenario: 梯度模式详情
- **WHEN** 查询梯度模式系列授权详情 - **WHEN** 查询梯度模式系列授权详情
- **THEN** 响应包含 commission_type="tiered"one_time_commission_amount=0commission_tiers=[{threshold, amount}, ...] - **THEN** 响应包含 `commission_type="tiered"``one_time_commission_amount=0`
- **AND** `commission_tiers` 中每个档位包含 `operator``dimension``stat_scope``threshold``amount` 五个字段
- **AND** `operator``dimension``stat_scope` 的值来自 PackageSeries 全局配置(对应 threshold 的档位),代理的 `amount` 来自 `ShopSeriesAllocation.commission_tiers_json`
#### Scenario: 梯度模式 dimension 为销售量
- **WHEN** 查询梯度模式授权详情PackageSeries 阶梯 `dimension = "sales_count"`
- **THEN** 响应中对应档位 `dimension = "sales_count"`,前端展示“销售量”条件
#### Scenario: 梯度模式 dimension 为销售额
- **WHEN** 查询梯度模式授权详情PackageSeries 阶梯 `dimension = "sales_amount"`
- **THEN** 响应中对应档位 `dimension = "sales_amount"`,前端展示“销售额”条件
#### Scenario: 梯度模式 stat_scope 区分
- **WHEN** 查询梯度模式授权详情
- **THEN** 响应中 `stat_scope` 正确反映 PackageSeries 配置的统计范围(`"self"``"self_and_sub"`
#### Scenario: 查询不存在的授权 #### Scenario: 查询不存在的授权
- **WHEN** 查询不存在的授权 ID - **WHEN** 查询不存在的授权 ID
- **THEN** 系统返回错误"授权记录不存在" - **THEN** 系统返回错误授权记录不存在
--- ---
@@ -179,3 +194,31 @@
#### Scenario: 有下级依赖时禁止删除 #### Scenario: 有下级依赖时禁止删除
- **WHEN** 删除一个已被下级代理用于创建子授权的记录 - **WHEN** 删除一个已被下级代理用于创建子授权的记录
- **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权" - **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"
---
### Requirement: 系列授权列表强充状态正确反映
系列授权列表 (`GET /shop-series-grants`) MUST 在每个列表项中返回 `force_recharge_locked`(是否被套餐系列锁定)和 `force_recharge_amount`(强充金额)。`force_recharge_enabled` MUST 反映有效状态:当锁定时为 `true`(无论分配记录自身如何);未锁定时取分配记录的实际设置。
#### Scenario: 套餐系列锁定强充
- **WHEN** 套餐系列配置 `enable_force_recharge=true``trigger_type=first_recharge`,查询列表
- **THEN** 列表项中 `force_recharge_locked=true``force_recharge_enabled=true``force_recharge_amount`=系列配置的 `force_amount`
#### Scenario: 代理自身开启强充(未锁定)
- **WHEN** 套餐系列未锁定强充,分配记录中 `enable_force_recharge=true`,查询列表
- **THEN** 列表项中 `force_recharge_locked=false``force_recharge_enabled=true``force_recharge_amount`=分配记录的实际金额
#### Scenario: 代理未开启强充(未锁定)
- **WHEN** 套餐系列未锁定强充,分配记录中 `enable_force_recharge=false`,查询列表
- **THEN** 列表项中 `force_recharge_locked=false``force_recharge_enabled=false``force_recharge_amount=0`
---
### Requirement: 查询系列授权详情强充有效状态
系列授权详情 (`GET /shop-series-grants/:id`) 中,`force_recharge_enabled` MUST 反映有效状态:当锁定时为 `true``force_recharge_amount` 锁定时应返回系列配置的 `force_amount`
#### Scenario: 锁定强充时详情响应
- **WHEN** 套餐系列锁定强充,查询对应分配记录详情
- **THEN** `force_recharge_locked=true``force_recharge_enabled=true``force_recharge_amount`=系列配置的 `force_amount`

View File

@@ -97,3 +97,40 @@
#### Scenario: 状态未变化 #### Scenario: 状态未变化
- **WHEN** 管理员设置的状态与当前状态相同 - **WHEN** 管理员设置的状态与当前状态相同
- **THEN** 系统正常返回成功,不产生错误 - **THEN** 系统正常返回成功,不产生错误
---
### Requirement: 套餐系列一次性佣金规则配置
系统 SHALL 在套餐系列层面配置一次性佣金的完整规则,包括触发条件、阈值、金额/梯度、时效、强充配置。梯度配置(`commission_type=tiered`)中每个档位 MUST 支持通过 `operator` 字段设置阈值比较运算符(`>``>=``<``<=`),默认值为 `>=`
#### Scenario: 配置首充规则
- **WHEN** 创建或更新套餐系列
- **AND** 设置一次性佣金规则:`trigger_type = first_recharge``threshold = 10000`100元`commission_amount = 2000`20元
- **THEN** 系统保存该规则配置
#### Scenario: 配置累计充值规则
- **WHEN** 创建或更新套餐系列
- **AND** 设置一次性佣金规则:`trigger_type = accumulated_recharge``threshold = 20000`200元`commission_amount = 4000`40元
- **THEN** 系统保存该规则配置
#### Scenario: 配置梯度规则(含 operator
- **WHEN** 创建或更新套餐系列,`commission_type = tiered`
- **AND** 梯度配置包含 `operator` 字段:`[{operator: ">=" , dimension: "sales_count", stat_scope: "self", threshold: 100, amount: 1000}, {operator: "<", dimension: "sales_count", stat_scope: "self", threshold: 50, amount: 500}]`
- **THEN** 系统保存完整梯度配置(含 operator
- **AND** 查询详情时响应中 `tiers` 包含 `operator` 字段
#### Scenario: 配置梯度规则(不传 operator向后兼容
- **WHEN** 创建或更新套餐系列,`commission_type = tiered`
- **AND** 梯度配置未提供 `operator` 字段:`[{dimension: "sales_count", stat_scope: "self", threshold: 100, amount: 1000}]`
- **THEN** 系统保存梯度配置,`operator` 存储为空值(计算引擎 fallback 到 `>=`
- **AND** 查询详情时响应中 `tiers``operator` 字段不出现omitempty
#### Scenario: 查询系列详情包含规则
- **WHEN** 查询套餐系列详情
- **THEN** 返回完整的一次性佣金规则配置,梯度档位包含 `operator``dimension``stat_scope``threshold``amount`