refactor: 一次性佣金配置从套餐级别提升到系列级别
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s

主要变更:
- 新增 tb_shop_series_allocation 表,存储系列级别的一次性佣金配置
- ShopPackageAllocation 移除 one_time_commission_amount 字段
- PackageSeries 新增 enable_one_time_commission 字段控制是否启用一次性佣金
- 新增 /api/admin/shop-series-allocations CRUD 接口
- 佣金计算逻辑改为从 ShopSeriesAllocation 获取一次性佣金金额
- 删除废弃的 ShopSeriesOneTimeCommissionTier 模型
- OpenAPI Tag '系列分配' 和 '单套餐分配' 合并为 '套餐分配'

迁移脚本:
- 000042: 重构佣金套餐模型
- 000043: 简化佣金分配
- 000044: 一次性佣金分配重构
- 000045: PackageSeries 添加 enable_one_time_commission 字段

测试:
- 新增验收测试 (shop_series_allocation, commission_calculation)
- 新增流程测试 (one_time_commission_chain)
- 删除过时的单元测试(已被验收测试覆盖)
This commit is contained in:
2026-02-04 14:28:44 +08:00
parent fba8e9e76b
commit b18ecfeb55
106 changed files with 9899 additions and 6608 deletions

View File

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

View File

@@ -0,0 +1,462 @@
## Context
### 背景
当前套餐与佣金系统在快速迭代中积累了技术债务,主要问题:
1. **套餐价格字段混乱**`Price``SuggestedCostPrice``SuggestedRetailPrice` 三个字段语义不清,不同场景使用不一致
2. **流量字段设计缺陷**`DataType` 暗示真/虚流量二选一,但业务需求是共存机制
3. **分配层次过多**:存在 `ShopSeriesAllocation`(系列分配)和 `ShopPackageAllocation`(套餐分配)两层,但业务模型只需要一层
4. **差价佣金计算复杂**:使用 `BaseCommissionMode/Value` 动态计算,但业务模型是简单的成本价差值
5. **一次性佣金配置位置错误**:配置在系列分配表中,应该只在套餐分配表中存储金额
### 业务模型Source of Truth
详见 [`docs/commission-package-model.md`](../../../docs/commission-package-model.md)
核心要点:
- **只有一层分配**`ShopPackageAllocation`(套餐分配)
- **差价佣金 = 下级成本价 - 自己成本价**(固定差值,无动态计算)
- **一次性佣金规则在系列定义,金额在套餐分配中设置**
### 现有架构(需要重构)
```
tb_package_series # 套餐系列(含一次性佣金规则)
├── tb_package # 套餐
└── tb_shop_series_allocation # ❌ 系列分配(需要删除)
└── tb_shop_package_allocation # 套餐分配
```
### 目标架构
```
tb_package_series # 套餐系列(含一次性佣金规则配置)
└── tb_package # 套餐
└── tb_shop_package_allocation # 套餐分配(唯一的分配表)
├── shop_id # 被分配的店铺
├── package_id # 套餐ID
├── series_id # 系列ID新增用于关联规则
├── allocator_shop_id # 分配者店铺ID新增
├── cost_price # 该代理的成本价
├── one_time_commission_amount # 该代理能拿到的一次性佣金
└── status
```
### 约束条件
- 不使用外键约束和 GORM 关联关系
- 必须支持向后兼容的数据迁移
- 分层架构Handler → Service → Store → Model
- 异步任务使用 Asynq
---
## Goals / Non-Goals
### Goals
1. **简化套餐模型**:只保留 `cost_price` + `suggested_retail_price`,语义清晰
2. **支持流量共存**:真流量必填 + 虚流量可选开关,停机判断逻辑统一
3. **删除 ShopSeriesAllocation**:移除多余的分配层次
4. **简化差价佣金计算**:直接使用成本价差值,删除动态计算逻辑
5. **统一分配模型**:一次性佣金金额只存储在 `ShopPackageAllocation`
6. **代理视角隔离**:不同代理看到自己的成本价和能拿到的一次性佣金
### Non-Goals
- 不重构订单支付流程(仅适配新的佣金计算)
- 不修改钱包充值逻辑(仅增加累计追踪)
- 不修改梯度佣金的统计存储结构(仅增加统计范围开关)
- 不处理历史订单的佣金重算(迁移只处理配置数据)
---
## Decisions
### D1: 删除 ShopSeriesAllocation 及相关表
**决策**:完全删除以下表和相关代码
| 表名 | 说明 | 删除原因 |
|------|------|----------|
| `tb_shop_series_allocation` | 系列分配表 | 多余的分配层次 |
| `tb_shop_series_allocation_config` | 系列分配配置版本表 | 依赖于 series_allocation |
| `tb_shop_series_one_time_commission_tier` | 系列分配梯度配置表 | 梯度配置移到系列规则中 |
**代码删除清单**
- `internal/model/shop_series_allocation.go`
- `internal/model/shop_series_allocation_config.go`
- `internal/model/dto/shop_series_allocation.go`
- `internal/store/postgres/shop_series_allocation_store.go`
- `internal/store/postgres/shop_series_allocation_config_store.go`
- `internal/service/shop_series_allocation/service.go`
- `internal/handler/admin/shop_series_allocation.go`
- `internal/routes/shop_series_allocation.go`
- `pkg/utils/commission.go`CalculateCostPrice 函数)
**理由**
- 业务模型只需要一层分配(套餐分配)
- 系列分配增加了不必要的复杂度
- 所有需要的信息都可以在套餐分配中存储
### D2: 修改 ShopPackageAllocation 模型
**决策**:扩展 `ShopPackageAllocation` 以承担原 `ShopSeriesAllocation` 的职责
```go
// Before
type ShopPackageAllocation struct {
ID uint
AllocationID uint // ❌ 外键到 ShopSeriesAllocation删除
PackageID uint
CostPrice int64
Status int
}
// After
type ShopPackageAllocation struct {
ID uint
ShopID uint // 被分配的店铺(新增)
PackageID uint // 套餐ID
SeriesID uint // 系列ID新增用于关联一次性佣金规则
AllocatorShopID uint // 分配者店铺ID新增0表示平台
CostPrice int64 // 该代理的成本价
OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金金额(新增)
Status int
Creator uint
Updater uint
}
```
**理由**
- `ShopID``AllocatorShopID` 原本存储在 `ShopSeriesAllocation`
- `SeriesID` 用于查询一次性佣金规则(从 `PackageSeries.OneTimeCommissionConfig` 获取)
- `OneTimeCommissionAmount` 存储分配时设置的金额
### D3: 差价佣金计算简化
**决策**:删除动态计算逻辑,使用固定成本价差值
```go
// Before: 动态计算(删除)
// pkg/utils/commission.go
func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
switch allocation.BaseCommissionMode {
case "fixed":
return orderAmount - allocation.BaseCommissionValue
case "percent":
return orderAmount * (100 - allocation.BaseCommissionValue) / 100
}
return orderAmount
}
// After: 简单差值计算
// 差价佣金 = 下级成本价 - 自己成本价
func CalculateDifferenceCommission(myCostPrice, subCostPrice int64) int64 {
return subCostPrice - myCostPrice
}
```
**示例**
```
平台成本价: 100
代理A成本价: 120分配时设置
代理A1成本价: 130A分配给A1时设置
当A1销售时
- A1利润 = 售价 - 130
- A差价佣金 = 130 - 120 = 10固定
- 平台收入 = 120
```
**理由**
- 业务模型明确定义差价佣金 = 下级成本价 - 自己成本价
- 无需 `BaseCommissionMode/Value` 的动态计算
- 简化代码,减少出错可能
### D4: 一次性佣金金额存储位置
**决策**:一次性佣金金额只存储在 `ShopPackageAllocation.OneTimeCommissionAmount`
```go
// 套餐系列定义规则(不变)
type PackageSeries struct {
// ... 基础字段
EnableOneTimeCommission bool // 是否启用
OneTimeCommissionConfig string // JSON触发条件、阈值、金额/梯度等
}
// 套餐分配记录每个代理能拿到的金额
type ShopPackageAllocation struct {
// ... 其他字段
OneTimeCommissionAmount int64 // 该代理能拿到的一次性佣金
}
```
**分配流程**
```
1. 平台创建套餐系列配置一次性佣金规则首充100返20
2. 平台分配给代理A设置成本价120一次性佣金20
→ 创建 ShopPackageAllocation(shop_id=A, cost_price=120, one_time_commission_amount=20)
3. 代理A分配给A1设置成本价130一次性佣金8
→ 创建 ShopPackageAllocation(shop_id=A1, cost_price=130, one_time_commission_amount=8)
4. 触发一次性佣金时:
- A1 获得8
- A 获得20 - 8 = 12
- 合计20 ✓
```
**约束**
- 给下级的金额 ≤ 自己能拿到的金额
- 给下级的金额 ≥ 0
**理由**
- 与成本价分配逻辑一致,都在套餐分配中设置
- 每个代理只存储"自己能拿到多少",计算简单
- 删除了 `ShopSeriesAllocation` 后的自然归属
### D5: 套餐价格字段简化
**决策**:移除 `Price``DataAmountMB`,保留并重命名字段
```go
// Before
type Package struct {
Price int64 // 语义不清
SuggestedCostPrice int64 // 建议成本价
SuggestedRetailPrice int64 // 建议售价
DataType string // real/virtual 二选一
RealDataMB int64
VirtualDataMB int64
DataAmountMB int64 // 语义不清
}
// After
type Package struct {
CostPrice int64 // 成本价(平台设置的基础成本价)
SuggestedRetailPrice int64 // 建议售价
RealDataMB int64 // 真实流量(必填)
EnableVirtualData bool // 是否启用虚流量
VirtualDataMB int64 // 虚流量(启用时必填,≤ 真实流量)
}
```
**理由**
- `Price` 在不同上下文含义不同,造成混乱
- `DataType` 是二选一设计,但业务需要共存
- `DataAmountMB` 没有明确定义是真流量还是虚流量
### D6: 代理视角套餐列表实现
**决策**:在 Service 层动态计算,从 `ShopPackageAllocation` 获取数据
```go
// PackageService.List 方法
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) (*dto.PackagePageResult, error) {
// 1. 查询基础套餐数据
packages, total, err := s.store.List(ctx, req)
// 2. 获取当前用户信息
userInfo := middleware.GetUserContextInfo(ctx)
// 3. 如果是代理用户,查询分配关系并填充视角数据
if userInfo.UserType == constants.UserTypeAgent {
allocations := s.packageAllocationStore.GetByShopAndPackages(ctx, userInfo.ShopID, packageIDs)
for _, pkg := range packages {
if alloc, ok := allocations[pkg.ID]; ok {
pkg.CostPrice = alloc.CostPrice // 覆盖为代理视角
pkg.OneTimeCommissionAmount = alloc.OneTimeCommissionAmount
}
}
}
return result, nil
}
```
**理由**
- 不存储冗余数据,避免一致性问题
- 查询时动态计算,逻辑集中在 Service 层
- 使用批量查询避免 N+1 问题
### D7: 累计充值追踪方案
**决策**:在 `IoTCard``Device` 模型中新增追踪字段
```go
type IoTCard struct {
// ... 现有字段
// 按套餐系列追踪JSON Map: series_id -> amount
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb"`
// 按套餐系列追踪首充状态JSON Map: series_id -> bool
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb"`
}
```
**理由**
- 累计充值和首充状态都是"按系列"的,需要按系列追踪
- 使用 JSONB 避免多表关联,查询性能好
- PostgreSQL 原生支持 JSONB 索引和查询
---
## 删除清单
### 需要删除的文件
| 文件路径 | 类型 | 说明 |
|----------|------|------|
| `internal/model/shop_series_allocation.go` | Model | 系列分配模型 |
| `internal/model/shop_series_allocation_config.go` | Model | 配置版本模型 |
| `internal/model/dto/shop_series_allocation.go` | DTO | 请求/响应 DTO |
| `internal/store/postgres/shop_series_allocation_store.go` | Store | 数据访问层 |
| `internal/store/postgres/shop_series_allocation_config_store.go` | Store | 配置版本 Store |
| `internal/store/postgres/shop_series_allocation_store_test.go` | Test | Store 测试 |
| `internal/service/shop_series_allocation/service.go` | Service | 业务逻辑层 |
| `internal/handler/admin/shop_series_allocation.go` | Handler | HTTP Handler |
| `internal/routes/shop_series_allocation.go` | Routes | 路由注册 |
| `tests/integration/shop_series_allocation_test.go` | Test | 集成测试 |
| `pkg/utils/commission.go` | Utils | CalculateCostPrice 函数 |
### 需要删除的字段
| 表/模型 | 字段 | 说明 |
|---------|------|------|
| `ShopPackageAllocation` | `AllocationID` | 外键到 ShopSeriesAllocation |
| `ShopSeriesAllocation` | 整表 | 删除整个表 |
| `ShopSeriesAllocationConfig` | 整表 | 删除整个表 |
### 需要修改的文件
`tasks.md` 中的详细任务列表。
---
## Risks / Trade-offs
### R1: 数据迁移复杂度
**风险**:现有数据结构与新结构差异大,迁移可能导致数据丢失或不一致
**缓解措施**
- 分阶段迁移:先新增字段,再迁移数据,最后删除旧字段
- 迁移前完整备份
- 迁移脚本支持回滚
- 新旧字段并存过渡期2周
### R2: API 破坏性变更
**风险**:前端需要同步修改,上线需要协调
**缓解措施**
- 提前沟通 API 变更内容
- 删除 `/api/admin/shop-series-allocations/*` 路由
- 修改 `/api/admin/shop-package-allocations/*` 路由参数
- 提供详细的迁移文档
### R3: 链式分配计算性能
**风险**:触发一次性佣金时需要沿代理链向上计算,可能涉及多级查询
**缓解措施**
- 使用 Redis 缓存代理链关系
- 限制代理层级(最多 7 级)
- 佣金分配使用异步任务处理
### R4: JSONB 字段查询性能
**风险**:累计充值和首充状态使用 JSONB 存储,复杂查询可能慢
**缓解措施**
- 为常用查询路径创建 JSONB 索引
- 触发检查时先查内存/Redis 缓存
- 监控查询性能,必要时重构为独立表
---
## Migration Plan
**注意**:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。
### 数据库变更
```sql
-- 1. ShopPackageAllocation 新增字段
ALTER TABLE tb_shop_package_allocation
ADD COLUMN IF NOT EXISTS shop_id BIGINT,
ADD COLUMN IF NOT EXISTS series_id BIGINT,
ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0;
-- 2. ShopPackageAllocation 删除字段
ALTER TABLE tb_shop_package_allocation
DROP COLUMN IF EXISTS allocation_id;
-- 3. Package 表调整
ALTER TABLE tb_package
DROP COLUMN IF EXISTS price,
DROP COLUMN IF EXISTS data_type,
DROP COLUMN IF EXISTS data_amount_mb,
ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false;
-- 4. IoTCard 新增追踪字段
ALTER TABLE tb_iot_card
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';
-- 5. Device 新增追踪字段
ALTER TABLE tb_device
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';
-- 6. 删除废弃表
DROP TABLE IF EXISTS tb_shop_series_allocation;
DROP TABLE IF EXISTS tb_shop_series_allocation_config;
DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier;
```
### 代码变更顺序
1. **Model 层**
- 修改 `ShopPackageAllocation` 模型
- 删除 `ShopSeriesAllocation` 相关模型
2. **DTO 层**
- 修改 `ShopPackageAllocation` DTO
- 删除 `ShopSeriesAllocation` DTO
3. **Store 层**
- 修改 `ShopPackageAllocationStore`
- 删除 `ShopSeriesAllocationStore`
4. **Service 层**
- 修改所有依赖 `ShopSeriesAllocation` 的 Service
- 删除 `ShopSeriesAllocationService`
5. **Handler/Routes 层**
- 删除 `ShopSeriesAllocationHandler`
- 删除相关路由注册
6. **Bootstrap 层**
- 移除所有 `ShopSeriesAllocation` 相关初始化
---
## Open Questions
1. **历史订单佣金**:已完成的订单佣金是否需要按新规则重算?
- 建议:不重算,保持历史数据稳定
2. **过渡期时长**:新旧字段并存多久?
- 建议2周观察期确认无问题后清理
3. **前端发版协调**:是否需要灰度发布?
- 取决于前端改动量,建议同步上线

View File

@@ -0,0 +1,99 @@
## Why
当前套餐与佣金系统存在**概念模型与实现错位**的问题:套餐价格字段过多且语义不清(`Price``SuggestedCostPrice``SuggestedRetailPrice` 三个字段用途混乱),流量字段设计不支持真流量/虚流量共存机制,一次性佣金缺少链式分配能力(无法设置"给下级多少"),套餐分配与系列分配的关系不清晰。这导致接口入参混乱、不同模块的一致性被破坏、操作流程不是线性的。需要基于梳理清楚的业务模型进行系统性重构。
## What Changes
### 套餐模型简化
- **BREAKING** 移除 `Package.Price` 字段,只保留 `cost_price`(成本价)和 `suggested_retail_price`(建议售价)
- **BREAKING** 重构流量字段:`real_data_mb`(必填)+ `enable_virtual_data`(开关)+ `virtual_data_mb`(可选,≤真流量)
- 移除 `data_type` 字段(不再是二选一)
- 移除 `data_amount_mb` 字段(语义不清)
### 一次性佣金重构
- **BREAKING** 修改触发条件命名:`single_recharge``first_recharge`(首充)
- **BREAKING** 新增链式分配能力:套餐分配时设置"给下级的一次性佣金金额"
- 新增一次性佣金时效配置(永久/固定日期/相对时长)
- 优化强充金额计算:首充时 `max(首充要求, 套餐售价)`;累计充值时支持固定/动态差额两种模式
- 新增梯度佣金统计范围开关(仅自己/自己+下级)
### 套餐分配统一
- **BREAKING** 统一套餐分配模型:将一次性佣金额度配置移入套餐分配
- 套餐系列仅定义一次性佣金"规则",分配时设置"给谁多少"
- 代理只能看到自己能拿到的一次性佣金额度,不能看到总规则
### 累计充值机制完善
- 明确累计范围:按卡/设备在该套餐系列下累计
- 明确累计操作:只有"充值"操作累计,"直接购买套餐"不累计
- 一次性佣金每张卡/设备只触发一次
### 代理视角优化
- 不同用户调用同一套餐列表接口,看到不同的成本价(自己的成本价)
- 不同用户看到不同的一次性佣金额度(自己能拿到的)
## Capabilities
### New Capabilities
- `commission-chain-distribution`: 一次性佣金链式分配能力,支持在套餐分配时设置给下级的佣金金额,自动计算各级代理分得的佣金
- `package-virtual-data`: 套餐真流量/虚流量共存机制,支持开关控制虚流量,停机判断基于配置选择使用哪个流量值
- `one-time-commission-validity`: 一次性佣金时效管理,支持永久、固定到期日期、相对时长三种时效类型
- `accumulated-recharge-tracking`: 累计充值追踪,按卡/设备在套餐系列下累计充值金额,只统计充值操作
### Modified Capabilities
- `package-management`: 移除 `Price``data_type``data_amount_mb` 字段,简化为 `cost_price` + `suggested_retail_price` + 真流量/虚流量共存
- `package-series-management`: 一次性佣金规则仅在系列层面定义,分配时不再复制完整配置
- `shop-series-allocation`: 移除完整的一次性佣金配置,改为引用系列规则 + 设置给下级的金额
- `one-time-commission-trigger`: 触发条件从 `single_recharge` 改为 `first_recharge`,首充定义为该卡/设备在该系列下的第一次充值
- `commission-calculation`: 适配链式分配逻辑,上级佣金 = 自己能拿的 - 给下级的
- `force-recharge-check`: 首充强充金额改为 `max(首充要求, 套餐售价)`,累计充值强充支持固定/动态两种计算方式
- `agent-available-packages`: 返回代理视角的成本价和一次性佣金额度,而非原始配置
- `shop-commission-tier`: 新增统计范围开关(仅自己/自己+下级),统计周期与一次性佣金时效一致
## Impact
### 数据库变更
- `tb_package`: 移除 `price``data_type``data_amount_mb` 字段,新增 `enable_virtual_data` 字段
- `tb_package_series`: 新增一次性佣金时效字段(`validity_type``validity_value`
- `tb_shop_series_allocation`: 移除大部分一次性佣金配置字段,仅保留 `one_time_commission_amount`(给下级的金额)
- `tb_shop_package_allocation`: 新增 `one_time_commission_amount` 字段
- `tb_iot_card` / `tb_device`: 新增 `accumulated_recharge_amount`(累计充值)、`first_recharge_triggered`(首充已触发)字段
- `tb_shop_series_one_time_commission_tier`: 新增 `stat_scope` 字段
### API 变更BREAKING
- `POST /api/admin/packages`: 移除 `price``data_type``data_amount_mb` 参数
- `PUT /api/admin/packages/:id`: 同上
- `POST /api/admin/shop-series-allocations`: 移除 `one_time_commission_type/trigger/threshold/mode/value` 等字段,新增 `one_time_commission_amount`
- `POST /api/admin/shop-package-allocations`: 新增 `one_time_commission_amount` 字段
- `GET /api/admin/packages`: 返回结构变化,成本价和一次性佣金按用户视角返回
### 代码变更
- `internal/model/package.go`: Package 结构体字段调整
- `internal/model/shop_series_allocation.go`: 移除一次性佣金配置字段
- `internal/model/shop_package_allocation.go`: 新增一次性佣金金额字段
- `internal/model/dto/package_dto.go`: 请求/响应 DTO 调整
- `internal/model/dto/shop_series_allocation.go`: DTO 简化
- `internal/service/commission/`: 佣金计算逻辑适配链式分配
- `internal/handler/admin/package.go`: 套餐列表按用户视角返回
- `internal/task/commission_calculation.go`: 异步任务适配新逻辑
### 前端影响
- 套餐管理页面:移除价格字段,调整流量配置 UI
- 套餐分配页面:简化一次性佣金配置,改为只设置"给下级多少"
- 套餐列表页面:显示的成本价和一次性佣金需要理解为"自己视角"
### 数据迁移
- 需要迁移脚本处理历史数据:
- `Package.SuggestedCostPrice``Package.cost_price`(如果 `Price` 有值需要决定保留哪个)
- 已有的 `ShopSeriesAllocation` 一次性佣金配置需要迁移到新结构

View File

@@ -0,0 +1,70 @@
# 累计充值追踪
## ADDED Requirements
### Requirement: 按卡/设备按系列累计
系统 SHALL 按照卡/设备在每个套餐系列下独立追踪累计充值金额。不同系列的累计互不影响。
#### Scenario: 同一卡在不同系列的累计
- **WHEN** IoT卡 A 在系列1下充值 100 元
- **AND** IoT卡 A 在系列2下充值 50 元
- **THEN** 系列1的累计金额 SHALL 为 100 元
- **AND** 系列2的累计金额 SHALL 为 50 元
#### Scenario: 同一卡在同一系列的累计
- **WHEN** IoT卡 A 在系列1下第一次充值 100 元
- **AND** IoT卡 A 在系列1下第二次充值 50 元
- **THEN** 系列1的累计金额 SHALL 为 150 元
### Requirement: 只有充值操作累计
系统 SHALL 只累计"充值到钱包"的操作直接购买套餐不经过钱包SHALL NOT 累计。
#### Scenario: 直接充值累计
- **WHEN** 客户选择充值 100 元到钱包
- **AND** 支付成功
- **THEN** 累计金额 SHALL 增加 100 元
#### Scenario: 直接购买不累计
- **WHEN** 客户直接购买 100 元套餐(余额足够)
- **AND** 系统从钱包扣款
- **THEN** 累计金额 SHALL 保持不变
#### Scenario: 强充购买累计
- **WHEN** 客户通过强充购买套餐
- **AND** 强充金额为 200 元
- **AND** 套餐价格为 100 元
- **THEN** 累计金额 SHALL 增加 200 元(充值部分)
- **AND** 钱包余额增加 200 后扣除 100
### Requirement: 首充状态按系列追踪
系统 SHALL 按照卡/设备在每个套餐系列下独立追踪首充状态。一个系列触发首充后,其他系列的首充状态不受影响。
#### Scenario: 首次在系列下充值
- **WHEN** IoT卡 A 从未在系列1下充值过
- **AND** IoT卡 A 进行充值操作
- **THEN** 系统 SHALL 标记该卡在系列1下的首充状态为"已触发"
#### Scenario: 非首次在系列下充值
- **WHEN** IoT卡 A 已在系列1下触发过首充
- **AND** IoT卡 A 再次充值
- **THEN** 系统 SHALL 不触发首充返佣
- **AND** 首充状态保持"已触发"
#### Scenario: 不同系列首充独立
- **WHEN** IoT卡 A 已在系列1下触发过首充
- **AND** IoT卡 A 首次在系列2下充值
- **THEN** 系统 SHALL 触发系列2的首充返佣如果规则启用
### Requirement: 一次性佣金只触发一次
每张卡/设备在每个套餐系列下一次性佣金无论首充还是累计充值SHALL 只触发一次。触发后不再重复触发。
#### Scenario: 累计充值达标后不再触发
- **WHEN** 系列规则为累计充值 200 返 40
- **AND** IoT卡 A 累计充值达到 200 元
- **AND** 系统触发一次性佣金 40 元
- **AND** IoT卡 A 继续充值 100 元(累计 300 元)
- **THEN** 系统 SHALL 不再触发一次性佣金

View File

@@ -0,0 +1,58 @@
# 代理可用套餐变更
## MODIFIED Requirements
### Requirement: 返回代理视角的套餐信息
代理查询套餐列表时,系统 SHALL 返回该代理视角的成本价和一次性佣金金额,而非原始配置。
**变更说明**:成本价和一次性佣金金额需要根据套餐分配关系动态计算。
#### Scenario: 代理查看套餐列表
- **WHEN** 代理A调用套餐列表接口
- **AND** 该套餐的基础成本价100元
- **AND** 平台给A分配时设置成本价120元
- **THEN** 返回的 `cost_price` SHALL 为 120元A的成本价
#### Scenario: 代理查看一次性佣金
- **WHEN** 代理A调用套餐列表接口
- **AND** 系列规则首充100返20元
- **AND** 平台给A设置的一次性佣金金额为15元
- **THEN** 返回的 `one_time_commission_amount` SHALL 为 15元
- **AND** 不返回系列规则的20元
#### Scenario: 平台查看套餐列表
- **WHEN** 平台管理员调用套餐列表接口
- **THEN** 返回基础成本价(不是代理视角)
- **AND** 返回完整的一次性佣金规则
---
### Requirement: 未分配套餐不可见
代理只能看到已分配给自己的套餐。未分配的套餐 SHALL NOT 出现在代理的套餐列表中。
#### Scenario: 只返回已分配套餐
- **WHEN** 代理A调用套餐列表接口
- **AND** 系统共有套餐 P1、P2、P3
- **AND** 只有 P1、P2 分配给了 A
- **THEN** 返回列表只包含 P1、P2
- **AND** 不包含 P3
---
### Requirement: 套餐分配新增一次性佣金金额
ShopPackageAllocation 模型 MUST 新增 `one_time_commission_amount` 字段,记录给该代理的一次性佣金金额。
**变更说明**:一次性佣金金额配置从系列分配移到套餐分配。
#### Scenario: 分配套餐时设置一次性佣金金额
- **WHEN** 上级给下级分配套餐
- **AND** 设置一次性佣金金额为10元
- **THEN** ShopPackageAllocation 记录 `one_time_commission_amount = 1000`(分)
#### Scenario: 一次性佣金金额约束
- **WHEN** 上级给下级设置一次性佣金金额
- **THEN** 该金额 MUST <= 上级自己能拿到的一次性佣金金额
- **AND** 该金额 MUST >= 0

View File

@@ -0,0 +1,72 @@
# 佣金计算变更
## MODIFIED Requirements
### Requirement: 一次性佣金计算
系统 SHALL 按链式分配规则计算一次性佣金。每级代理实际获得的佣金 = 自己能拿到的金额 - 给下级的金额。
**变更说明**:从"直接发放给归属店铺"改为"链式分配给代理链上所有店铺"。
#### Scenario: 计算链式分配金额
- **WHEN** 触发一次性佣金
- **AND** 代理链为 平台 → A → A1 → A2
- **AND** 系列规则返20元
- **AND** A能拿到20元给A1设置8元
- **AND** A1能拿到8元给A2设置5元
- **THEN** A2实际获得 = 5元
- **AND** A1实际获得 = 8 - 5 = 3元
- **AND** A实际获得 = 20 - 8 = 12元
#### Scenario: 末端代理全额获得
- **WHEN** 触发一次性佣金
- **AND** A1是末端代理无下级
- **AND** A1能拿到10元
- **THEN** A1实际获得 = 10元全额
#### Scenario: 独吞场景
- **WHEN** 触发一次性佣金
- **AND** A给A1设置的一次性佣金金额为0元
- **THEN** A1实际获得 = 0元
- **AND** A实际获得 = 自己能拿到的全部金额
---
### Requirement: 梯度佣金计算
系统 SHALL 根据代理当前销量/销售额所在梯度档位计算一次性佣金金额。
**变更说明**:新增统计范围开关(仅自己/自己+下级),梯度升级后上级获得增量。
#### Scenario: 按梯度计算
- **WHEN** 触发一次性佣金
- **AND** 代理A当前销量150适用">=100返10元"档位)
- **AND** A给A1设置5元
- **THEN** A1实际获得 = 5元
- **AND** A实际获得 = 10 - 5 = 5元
#### Scenario: 梯度升级
- **WHEN** 代理A销量从150升到210
- **AND** 适用档位从">=100返10元"变为">=200返20元"
- **AND** A给A1设置仍为5元
- **THEN** A1实际获得 = 5元不变
- **AND** A实际获得 = 20 - 5 = 15元增量归上级
#### Scenario: 统计范围-仅自己
- **WHEN** 梯度配置 `stat_scope = self`
- **THEN** 只统计该代理直接产生的销量/销售额
#### Scenario: 统计范围-自己+下级
- **WHEN** 梯度配置 `stat_scope = self_and_sub`
- **THEN** 统计该代理及所有下级代理的销量/销售额之和
---
### Requirement: 差价佣金计算
差价佣金计算规则不变:上级代理的佣金 = 下级成本价 - 自己成本价。
#### Scenario: 差价佣金计算
- **WHEN** 代理A1销售一单
- **AND** A的成本价120元A1的成本价130元
- **THEN** A的差价佣金 = 130 - 120 = 10元

View File

@@ -0,0 +1,61 @@
# 一次性佣金链式分配
## ADDED Requirements
### Requirement: 分配时设置下级一次性佣金金额
系统 SHALL 允许上级代理在分配套餐时设置给下级的一次性佣金金额。该金额 MUST 小于等于上级自己能拿到的一次性佣金金额,且 MUST 大于等于 0。
#### Scenario: 设置有效的下级佣金金额
- **WHEN** 代理A分配套餐给代理A1设置一次性佣金金额为 10 元
- **AND** 代理A自己能拿到的一次性佣金为 20 元
- **THEN** 系统 SHALL 保存该配置
- **AND** 代理A1的一次性佣金金额记录为 10 元
#### Scenario: 设置超额的下级佣金金额
- **WHEN** 代理A分配套餐给代理A1设置一次性佣金金额为 25 元
- **AND** 代理A自己能拿到的一次性佣金为 20 元
- **THEN** 系统 SHALL 拒绝该配置
- **AND** 返回错误"给下级的一次性佣金不能超过自己能拿到的金额"
#### Scenario: 设置零佣金(独吞)
- **WHEN** 代理A分配套餐给代理A1设置一次性佣金金额为 0 元
- **THEN** 系统 SHALL 保存该配置
- **AND** 代理A1的一次性佣金金额记录为 0 元
### Requirement: 链式佣金分配计算
当一次性佣金触发时,系统 SHALL 沿代理链向上计算并分配佣金。每级代理实际获得的佣金 = 自己能拿到的金额 - 给下级的金额。
#### Scenario: 三级代理链佣金分配
- **WHEN** 代理链为 平台 → A → A1 → A2
- **AND** 系列规则首充100返20元
- **AND** 平台给A设置20元
- **AND** A给A1设置8元
- **AND** A1给A2设置5元
- **AND** A2的客户触发首充
- **THEN** A2 SHALL 获得 5 元
- **AND** A1 SHALL 获得 8 - 5 = 3 元
- **AND** A SHALL 获得 20 - 8 = 12 元
- **AND** 总分配金额 = 20 元
#### Scenario: 末端代理无下级
- **WHEN** 代理A1是末端代理无下级
- **AND** A1的客户触发首充
- **AND** A1能拿到的一次性佣金为 10 元
- **THEN** A1 SHALL 获得完整的 10 元
### Requirement: 代理只能看到自己的一次性佣金金额
代理查看套餐列表时,系统 SHALL 只返回该代理能拿到的一次性佣金金额,不得返回系列规则的总金额或其他代理的配置。
#### Scenario: 代理A查看套餐
- **WHEN** 代理A调用套餐列表接口
- **AND** 该套餐的系列规则为首充100返20元
- **AND** 平台给A设置的一次性佣金为 15 元
- **THEN** 返回的一次性佣金金额 SHALL 为 15 元
- **AND** 不得返回 20 元(总规则)
#### Scenario: 平台查看套餐
- **WHEN** 平台管理员调用套餐列表接口
- **THEN** 返回的一次性佣金 SHALL 显示完整规则首充100返20元

View File

@@ -0,0 +1,72 @@
# 强充检查变更
## MODIFIED Requirements
### Requirement: 首充强充金额计算
当系列启用首充一次性佣金时,强充金额 SHALL 为 max(首充要求, 套餐售价)。
**变更说明**:明确首充强充金额的计算公式。
#### Scenario: 首充要求小于套餐价格
- **WHEN** 首充要求50元套餐售价100元
- **THEN** 强充金额 = 100元取套餐价格
#### Scenario: 首充要求等于套餐价格
- **WHEN** 首充要求100元套餐售价100元
- **THEN** 强充金额 = 100元
#### Scenario: 首充要求大于套餐价格
- **WHEN** 首充要求200元套餐售价100元
- **THEN** 强充金额 = 200元取首充要求
---
### Requirement: 累计充值强充金额计算
当系列启用累计充值一次性佣金且启用强充时,系统 SHALL 支持两种强充金额计算方式:固定金额和动态差额。
**变更说明**:新增强充金额计算方式开关。
#### Scenario: 固定金额模式
- **WHEN** 强充配置 `force_calc_type = fixed`
- **AND** `force_amount = 10000`100元
- **THEN** 强充金额 = 100元固定值
#### Scenario: 动态差额模式
- **WHEN** 强充配置 `force_calc_type = dynamic`
- **AND** 累计要求200元
- **AND** 当前已累计80元
- **THEN** 强充金额 = 200 - 80 = 120元差额
#### Scenario: 动态差额已达标
- **WHEN** 强充配置 `force_calc_type = dynamic`
- **AND** 累计要求200元
- **AND** 当前已累计250元
- **THEN** 强充金额 = 0元已达标无需强充
---
### Requirement: 强充流程
强充流程保持不变:先创建充值订单,支付成功后钱进入钱包,然后自动扣款购买套餐。
#### Scenario: 首充强充流程
- **WHEN** 客户购买套餐触发首充强充
- **AND** 强充金额200元套餐售价100元
- **THEN** 创建充值订单200元
- **AND** 支付成功后钱包余额+200
- **AND** 标记首充状态为"已触发"
- **AND** 自动创建套餐购买订单并扣款100元
- **AND** 触发首充返佣(按链式分配)
- **AND** 钱包剩余100元
#### Scenario: 累计充值强充流程
- **WHEN** 客户购买套餐触发累计充值强充
- **AND** 强充金额120元套餐售价100元
- **THEN** 创建充值订单120元
- **AND** 支付成功后钱包余额+120
- **AND** 累计金额 += 120
- **AND** 自动创建套餐购买订单并扣款100元
- **AND** 如果累计达标则触发返佣
- **AND** 钱包剩余20元

View File

@@ -0,0 +1,99 @@
# 一次性佣金触发变更
## MODIFIED Requirements
### Requirement: 一次性充值触发佣金
系统 SHALL 支持"首充"触发条件:当该卡/设备在该套餐系列下首次充值且金额 ≥ 配置阈值时触发一次性佣金。
**变更说明**:将 `single_recharge` 重命名为 `first_recharge`(首充),强调是"第一次"充值而非"单次"充值。
#### Scenario: 首充达到阈值
- **WHEN** IoT卡在该系列下首次充值 500 元
- **AND** 配置阈值 300 元
- **AND** 该卡在该系列下未触发过首充返佣
- **THEN** 系统按链式分配规则发放一次性佣金
- **AND** 标记该卡在该系列下的首充状态为"已触发"
#### Scenario: 首充未达到阈值
- **WHEN** IoT卡在该系列下首次充值 200 元
- **AND** 配置阈值 300 元
- **THEN** 系统不发放一次性佣金
- **AND** 首充状态保持"未触发"
#### Scenario: 非首次充值
- **WHEN** IoT卡在该系列下已触发过首充
- **AND** 再次充值 500 元(≥阈值)
- **THEN** 系统不发放一次性佣金
---
### Requirement: 累计充值触发佣金
系统 SHALL 支持"累计充值"触发条件:当卡/设备在该套餐系列下的累计充值金额 ≥ 配置阈值时触发一次性佣金。
**变更说明**:累计范围改为按"套餐系列"累计,而非全局累计。只有充值操作累计,直接购买套餐不累计。
#### Scenario: 累计达到阈值
- **WHEN** IoT卡在该系列下之前累计充值 200 元
- **AND** 本次充值 150 元
- **AND** 配置阈值 300 元
- **THEN** 系统更新该系列的累计充值为 350 元
- **AND** 累计 350 元 ≥ 300 元,系统按链式分配规则发放一次性佣金
- **AND** 标记该卡在该系列下已触发累计充值返佣
#### Scenario: 累计未达到阈值
- **WHEN** IoT卡在该系列下累计充值为 100 元
- **AND** 本次充值 100 元
- **AND** 配置阈值 300 元
- **THEN** 系统更新累计充值为 200 元
- **AND** 累计 200 元 < 300 元,系统不发放一次性佣金
#### Scenario: 直接购买不累计
- **WHEN** IoT卡直接购买套餐不经过充值
- **THEN** 该系列的累计充值金额不变
---
### Requirement: 一次性佣金只发放一次
每张卡/设备在每个套餐系列下,一次性佣金 SHALL 只发放一次,无论是首充还是累计充值触发。通过按系列追踪的状态字段控制。
**变更说明**:首充状态和累计充值触发状态改为按套餐系列追踪,不同系列互不影响。
#### Scenario: 首次触发
- **WHEN** 首次满足触发条件(首充或累计充值)
- **THEN** 按链式分配规则发放佣金
- **AND** 设置该系列的触发状态为 true
#### Scenario: 再次满足条件
- **WHEN** 再次满足触发条件
- **AND** 该系列的触发状态已为 true
- **THEN** 不发放佣金
#### Scenario: 不同系列独立
- **WHEN** IoT卡在系列1已触发一次性佣金
- **AND** IoT卡首次满足系列2的触发条件
- **THEN** 系统发放系列2的一次性佣金如果规则启用
---
### Requirement: 一次性佣金发放对象
一次性佣金 SHALL 按链式分配规则发放给代理链上的所有相关店铺。
**变更说明**:从"发放给直接归属店铺"改为"链式分配给代理链上所有店铺"。
#### Scenario: 链式发放
- **WHEN** IoT卡归属代理A1
- **AND** 代理链为 平台 → A → A1
- **AND** 触发一次性佣金系列规则返20元
- **AND** A能拿到20元给A1设置10元
- **THEN** A1获得10元
- **AND** A获得20-10=10元
## RENAMED Requirements
### Requirement: single_recharge 触发类型
- **FROM**: `single_recharge`(单次充值)
- **TO**: `first_recharge`(首充)

View File

@@ -0,0 +1,54 @@
# 一次性佣金时效管理
## ADDED Requirements
### Requirement: 时效类型配置
系统 SHALL 支持三种一次性佣金时效类型永久permanent、固定日期fixed_date、相对时长relative
#### Scenario: 配置永久有效
- **WHEN** 配置一次性佣金规则时设置 `validity_type = permanent`
- **THEN** 系统 SHALL 保存该配置
- **AND** 该规则永久有效,不会过期
#### Scenario: 配置固定到期日期
- **WHEN** 配置一次性佣金规则时设置 `validity_type = fixed_date`
- **AND** `validity_value = "2025-12-31"`
- **THEN** 系统 SHALL 保存该配置
- **AND** 该规则在 2025-12-31 23:59:59 后失效
#### Scenario: 配置相对时长
- **WHEN** 配置一次性佣金规则时设置 `validity_type = relative`
- **AND** `validity_value = "3"` (表示 3 个月)
- **THEN** 系统 SHALL 保存该配置
- **AND** 该规则从创建时间起 3 个月后失效
### Requirement: 过期规则不触发返佣
当一次性佣金规则过期时,系统 SHALL 不再触发返佣,即使满足触发条件(首充/累计充值)。
#### Scenario: 规则过期后首充
- **WHEN** 一次性佣金规则已过期
- **AND** 客户首充达到阈值
- **THEN** 系统 SHALL 不触发一次性佣金
- **AND** 正常完成充值和套餐购买
#### Scenario: 规则有效期内首充
- **WHEN** 一次性佣金规则在有效期内
- **AND** 客户首充达到阈值
- **THEN** 系统 SHALL 触发一次性佣金
- **AND** 按链式分配规则分配佣金
### Requirement: 梯度统计周期与时效一致
当一次性佣金使用梯度模式时,梯度统计周期(销量/销售额SHALL 与一次性佣金时效一致。时效结束后统计归零。
#### Scenario: 时效内统计
- **WHEN** 一次性佣金时效为 3 个月
- **AND** 使用销量梯度
- **THEN** 销量统计 SHALL 只计算这 3 个月内的销量
#### Scenario: 时效结束后统计重置
- **WHEN** 一次性佣金时效到期
- **AND** 配置了新的一次性佣金时效
- **THEN** 销量/销售额统计 SHALL 从零开始

View File

@@ -0,0 +1,74 @@
# 套餐管理变更
## MODIFIED Requirements
### Requirement: 创建套餐
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置(真流量必填、虚流量可选)、成本价和建议售价。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
**变更说明**:移除 `price``data_type``data_amount_mb` 参数,新增 `enable_virtual_data` 参数。
#### Scenario: 成功创建套餐
- **WHEN** 管理员提交有效的套餐信息
- **AND** 包含 `cost_price``suggested_retail_price``real_data_mb`
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
#### Scenario: 创建带虚流量的套餐
- **WHEN** 管理员提交套餐信息
- **AND** `enable_virtual_data = true`
- **AND** `virtual_data_mb = 800`
- **AND** `real_data_mb = 1000`
- **THEN** 系统创建套餐记录,虚流量配置正确保存
#### Scenario: 套餐编码重复
- **WHEN** 管理员提交的套餐编码已存在(未删除)
- **THEN** 系统返回错误 "套餐编码已存在"
#### Scenario: 关联不存在的套餐系列
- **WHEN** 管理员指定的系列 ID 不存在
- **THEN** 系统返回错误 "套餐系列不存在"
#### Scenario: 缺少必填字段
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、成本价、真流量)
- **THEN** 系统返回参数验证错误
---
### Requirement: Package 模型新增字段
系统 MUST 在 Package 模型中调整以下字段:
- 移除 `price` 字段
- 移除 `data_type` 字段
- 移除 `data_amount_mb` 字段
- 保留 `suggested_cost_price` 并重命名为 `cost_price`:成本价(分为单位)
- 保留 `suggested_retail_price`:建议售价(分为单位)
- 新增 `enable_virtual_data`:是否启用虚流量,布尔值,默认 false
- 保留 `real_data_mb`:真实流量(必填)
- 保留 `virtual_data_mb`:虚流量(启用时必填)
- 保留 `shelf_status`上架状态1-上架 2-下架,默认 2
#### Scenario: 创建套餐时设置价格
- **WHEN** 管理员创建套餐并设置成本价和建议售价
- **THEN** 系统保存 `cost_price``suggested_retail_price`
#### Scenario: 查询套餐时返回价格
- **WHEN** 管理员查询套餐详情或列表
- **THEN** 响应中包含 `cost_price``suggested_retail_price``shelf_status``enable_virtual_data` 字段
- **AND** 不再返回 `price``data_type``data_amount_mb` 字段
## REMOVED Requirements
### Requirement: price 字段
**Reason**: `price` 字段语义不清,与 `suggested_cost_price``suggested_retail_price` 混淆
**Migration**: 使用 `cost_price`(成本价)和 `suggested_retail_price`(建议售价)替代
### Requirement: data_type 字段
**Reason**: `data_type` 暗示真流量/虚流量二选一,但业务需求是共存
**Migration**: 使用 `enable_virtual_data` 开关控制是否启用虚流量
### Requirement: data_amount_mb 字段
**Reason**: 语义不清,不知道是真流量还是虚流量
**Migration**: 使用 `real_data_mb`(真流量)和 `virtual_data_mb`(虚流量)明确区分

View File

@@ -0,0 +1,55 @@
# 套餐系列管理变更
## MODIFIED Requirements
### Requirement: 套餐系列一次性佣金规则配置
系统 SHALL 在套餐系列层面配置一次性佣金的完整规则,包括触发条件、阈值、金额/梯度、时效、强充配置。
**变更说明**:一次性佣金规则从分配时配置改为在系列层面统一定义。分配时只设置"给下级多少"。
#### 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: 配置梯度规则
- **WHEN** 创建或更新套餐系列
- **AND** 设置一次性佣金规则:`commission_type = tiered`
- **AND** 梯度配置:销量>=0返5元>=100返10元>=200返20元
- **THEN** 系统保存梯度配置
#### Scenario: 配置时效
- **WHEN** 创建或更新套餐系列
- **AND** 设置时效:`validity_type = relative``validity_value = 3`3个月
- **THEN** 系统保存时效配置
- **AND** 该规则在3个月后失效
---
### Requirement: PackageSeries 模型新增字段
系统 MUST 在 PackageSeries 模型中新增一次性佣金规则配置字段:
**新增字段**(使用 JSONB 存储):
- `one_time_commission_config`一次性佣金规则配置JSON
- `enable`:是否启用
- `trigger_type`触发类型first_recharge / accumulated_recharge
- `threshold`:触发阈值(分)
- `commission_type`返佣类型fixed / tiered
- `commission_amount`:固定返佣金额(分)
- `tiers`:梯度配置数组
- `validity_type`时效类型permanent / fixed_date / relative
- `validity_value`:时效值
- `enable_force_recharge`:是否启用强充
- `force_calc_type`强充金额计算方式fixed / dynamic
- `force_amount`强充金额fixed类型时
#### Scenario: 查询系列详情包含规则
- **WHEN** 查询套餐系列详情
- **THEN** 返回完整的一次性佣金规则配置

View File

@@ -0,0 +1,62 @@
# 套餐真流量/虚流量共存机制
## ADDED Requirements
### Requirement: 真流量必填
创建或更新套餐时,系统 SHALL 要求 `real_data_mb`(真实流量额度)为必填字段,且 MUST 大于 0。
#### Scenario: 创建套餐时提供真流量
- **WHEN** 创建套餐请求包含 `real_data_mb = 1000`
- **THEN** 系统 SHALL 保存该套餐
- **AND** `real_data_mb` 记录为 1000 MB
#### Scenario: 创建套餐时缺少真流量
- **WHEN** 创建套餐请求未提供 `real_data_mb` 字段
- **THEN** 系统 SHALL 拒绝请求
- **AND** 返回参数验证失败错误
### Requirement: 虚流量可选开关
系统 SHALL 提供 `enable_virtual_data` 开关控制是否启用虚流量。启用时 MUST 提供 `virtual_data_mb`,且该值 MUST 小于等于 `real_data_mb`
#### Scenario: 启用虚流量
- **WHEN** 创建套餐请求包含 `enable_virtual_data = true`
- **AND** `virtual_data_mb = 800`
- **AND** `real_data_mb = 1000`
- **THEN** 系统 SHALL 保存该配置
- **AND** `enable_virtual_data` 记录为 true
- **AND** `virtual_data_mb` 记录为 800 MB
#### Scenario: 启用虚流量但未提供额度
- **WHEN** 创建套餐请求包含 `enable_virtual_data = true`
- **AND** 未提供 `virtual_data_mb`
- **THEN** 系统 SHALL 拒绝请求
- **AND** 返回"启用虚流量时必须提供虚流量额度"错误
#### Scenario: 虚流量超过真流量
- **WHEN** 创建套餐请求包含 `enable_virtual_data = true`
- **AND** `virtual_data_mb = 1200`
- **AND** `real_data_mb = 1000`
- **THEN** 系统 SHALL 拒绝请求
- **AND** 返回"虚流量不能超过真实流量"错误
#### Scenario: 不启用虚流量
- **WHEN** 创建套餐请求包含 `enable_virtual_data = false`
- **THEN** 系统 SHALL 保存该配置
- **AND** `virtual_data_mb` 可为空或忽略
### Requirement: 停机判断目标值
轮询停机模块在判断是否停机时,系统 SHALL 根据 `enable_virtual_data` 选择目标值:启用虚流量时使用 `virtual_data_mb`,否则使用 `real_data_mb`
#### Scenario: 启用虚流量的停机判断
- **WHEN** 套餐配置 `enable_virtual_data = true`
- **AND** `virtual_data_mb = 800`
- **AND** `real_data_mb = 1000`
- **THEN** 停机判断的目标流量值 SHALL 为 800 MB
#### Scenario: 未启用虚流量的停机判断
- **WHEN** 套餐配置 `enable_virtual_data = false`
- **AND** `real_data_mb = 1000`
- **THEN** 停机判断的目标流量值 SHALL 为 1000 MB

View File

@@ -0,0 +1,53 @@
# 店铺佣金梯度变更
## MODIFIED Requirements
### Requirement: 梯度统计范围开关
系统 SHALL 支持配置梯度佣金的统计范围仅自己self或自己+下级self_and_sub
**变更说明**:新增 `stat_scope` 字段,允许配置统计是否包含下级代理的销量/销售额。
#### Scenario: 配置统计范围-仅自己
- **WHEN** 配置梯度规则时设置 `stat_scope = self`
- **THEN** 系统保存该配置
- **AND** 计算梯度时只统计该代理直接产生的销量/销售额
#### Scenario: 配置统计范围-自己+下级
- **WHEN** 配置梯度规则时设置 `stat_scope = self_and_sub`
- **THEN** 系统保存该配置
- **AND** 计算梯度时统计该代理及所有下级代理的销量/销售额之和
---
### Requirement: 梯度统计周期
梯度佣金的统计周期 SHALL 与一次性佣金时效一致。时效结束后统计归零。
**变更说明**:统计周期从独立配置改为与一次性佣金时效绑定。
#### Scenario: 时效内统计
- **WHEN** 一次性佣金时效为3个月relative = 3
- **THEN** 销量/销售额统计只计算这3个月内的数据
#### Scenario: 时效结束统计重置
- **WHEN** 一次性佣金时效到期
- **AND** 配置了新的时效周期
- **THEN** 销量/销售额统计从零开始
#### Scenario: 永久时效
- **WHEN** 一次性佣金时效为永久permanent
- **THEN** 销量/销售额永久累计,不会重置
---
### Requirement: ShopSeriesOneTimeCommissionTier 模型新增字段
系统 MUST 在 ShopSeriesOneTimeCommissionTier 模型中新增统计范围字段:
**新增字段**
- `stat_scope`统计范围varchar(20),可选值 `self`/`self_and_sub`,默认 `self`
#### Scenario: 查询梯度配置包含统计范围
- **WHEN** 查询梯度配置详情
- **THEN** 返回包含 `stat_scope` 字段

View File

@@ -0,0 +1,71 @@
# 店铺系列分配变更
## MODIFIED Requirements
### Requirement: 创建店铺系列分配
系统 SHALL 允许上级店铺为下级店铺分配套餐系列。分配时配置基础返佣和一次性佣金金额(给下级的金额)。
**变更说明**移除完整的一次性佣金配置字段type、trigger、threshold、mode、value 等),改为只配置"给被分配店铺的一次性佣金金额"。一次性佣金规则从套餐系列获取。
#### Scenario: 创建分配并设置一次性佣金金额
- **WHEN** 平台给代理A分配系列
- **AND** 系列启用一次性佣金规则为首充100返20元
- **AND** 设置给A的一次性佣金金额为20元
- **THEN** 系统创建分配记录
- **AND** `one_time_commission_amount` 记录为 2000
#### Scenario: 设置超额的一次性佣金金额
- **WHEN** 代理A给代理A1分配系列
- **AND** A自己能拿到的一次性佣金为15元
- **AND** 设置给A1的一次性佣金金额为20元
- **THEN** 系统拒绝该配置
- **AND** 返回错误"给下级的一次性佣金不能超过自己能拿到的金额"
#### Scenario: 系列未启用一次性佣金
- **WHEN** 分配的系列未启用一次性佣金
- **THEN** 不需要设置 `one_time_commission_amount`
- **AND** 该字段默认为 0
---
### Requirement: ShopSeriesAllocation 模型字段调整
系统 MUST 调整 ShopSeriesAllocation 模型字段:
**保留字段**
- `shop_id`被分配的店铺ID
- `series_id`套餐系列ID
- `allocator_shop_id`分配者店铺ID
- `base_commission_mode`:基础返佣模式
- `base_commission_value`:基础返佣值
- `status`:状态
**新增字段**
- `one_time_commission_amount`给被分配店铺的一次性佣金金额默认0
**移除字段**
- `enable_one_time_commission`
- `one_time_commission_type`
- `one_time_commission_trigger`
- `one_time_commission_threshold`
- `one_time_commission_mode`
- `one_time_commission_value`
- `enable_force_recharge`
- `force_recharge_amount`
- `force_recharge_trigger_type`
#### Scenario: 查询分配详情
- **WHEN** 查询店铺系列分配详情
- **THEN** 返回 `one_time_commission_amount`(给该店铺的一次性佣金金额)
- **AND** 不再返回完整的一次性佣金配置字段
## REMOVED Requirements
### Requirement: 分配时配置完整一次性佣金规则
**Reason**: 一次性佣金规则应在套餐系列层面定义,分配时只需设置"给下级多少"
**Migration**:
1. 一次性佣金规则(触发条件、阈值、金额/梯度)移到套餐系列的配置中
2. 分配时只配置 `one_time_commission_amount`(给该代理的金额)
3. 迁移脚本将现有 `one_time_commission_value` 迁移到新字段

View File

@@ -0,0 +1,125 @@
# 套餐与佣金模型重构任务列表
> **注意**:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。
## 1. 数据库迁移
- [x] 1.1 创建迁移文件Package 表移除 `price`/`data_type`/`data_amount_mb` 字段,新增 `enable_virtual_data` 字段
- [x] 1.2 创建迁移文件ShopPackageAllocation 表新增 `one_time_commission_amount` 字段
- [x] 1.3 创建迁移文件IoTCard 表新增 `accumulated_recharge_by_series``first_recharge_triggered_by_series` 字段jsonb
- [x] 1.4 创建迁移文件Device 表新增 `accumulated_recharge_by_series``first_recharge_triggered_by_series` 字段jsonb
- [x] 1.5 创建迁移文件PackageSeries 表新增 `one_time_commission_config` 字段jsonb
- [x] 1.6 创建迁移文件ShopSeriesOneTimeCommissionTier 表新增 `stat_scope` 字段
- [x] 1.7 创建迁移文件ShopSeriesAllocation 表移除一次性佣金配置字段,新增 `one_time_commission_amount` 字段
- [x] 1.8 执行迁移并验证
## 2. Model 层更新
- [x] 2.1 更新 Package 模型:移除 `Price`/`DataType`/`DataAmountMB` 字段,新增 `EnableVirtualData` 字段
- [x] 2.2 更新 ShopPackageAllocation 模型:新增 `OneTimeCommissionAmount` 字段
- [x] 2.3 更新 IoTCard 模型:新增 `AccumulatedRechargeBySeriesJSON``FirstRechargeTriggeredBySeriesJSON` 字段
- [x] 2.4 更新 Device 模型:新增 `AccumulatedRechargeBySeriesJSON``FirstRechargeTriggeredBySeriesJSON` 字段
- [x] 2.5 更新 PackageSeries 模型:新增 `OneTimeCommissionConfigJSON` 字段
- [x] 2.6 创建 OneTimeCommissionConfig 结构体(含 Enable, TriggerType, Threshold, CommissionType, ValidityType 等字段)
- [x] 2.7 更新 ShopSeriesOneTimeCommissionTier 模型:新增 `StatScope` 字段
- [x] 2.8 更新 ShopSeriesAllocation 模型:移除一次性佣金配置字段,新增 `OneTimeCommissionAmount` 字段
- [x] 2.9 为 IoTCard/Device 添加累计充值和首充状态的 getter/setter 辅助方法
- [x] 2.10 运行 `lsp_diagnostics` 验证 Model 层无编译错误
## 3. DTO 层更新
- [x] 3.1 更新 CreatePackageRequest移除 `price`/`data_type`/`data_amount_mb`,新增 `enable_virtual_data`
- [x] 3.2 更新 UpdatePackageRequest同上调整字段
- [x] 3.3 更新 PackageResponse移除废弃字段新增 `enable_virtual_data``one_time_commission_amount`
- [x] 3.4 更新 CreateShopPackageAllocationRequest新增 `one_time_commission_amount` 字段
- [x] 3.5 更新 ShopPackageAllocationResponse新增 `one_time_commission_amount` 字段
- [x] 3.6 更新 CreatePackageSeriesRequest新增 `one_time_commission_config` 嵌套结构
- [x] 3.7 更新 PackageSeriesResponse返回一次性佣金规则配置
- [x] 3.8 简化 CreateShopSeriesAllocationRequest移除完整一次性佣金配置字段改为 `one_time_commission_amount`
- [x] 3.9 简化 ShopSeriesAllocationResponse移除完整一次性佣金配置字段
- [x] 3.10 运行 `lsp_diagnostics` 验证 DTO 层无编译错误
## 4. Store 层更新
- [x] 4.1 更新 PackageStore适配新字段的 CRUD 操作GORM Save 自动处理)
- [x] 4.2 更新 ShopPackageAllocationStore支持 `one_time_commission_amount` 字段GORM Save 自动处理)
- [x] 4.3 更新 PackageSeriesStore支持 `one_time_commission_config` JSON 字段的读写GORM Save 自动处理)
- [x] 4.4 更新 ShopSeriesAllocationStore移除完整一次性佣金配置字段的处理Model 层已移除字段)
- [x] 4.5 新增 IoTCardStore 方法UpdateRechargeTrackingFields
- [x] 4.6 新增 IoTCardStore 方法:(通过 Model 层 getter/setter 辅助方法实现)
- [x] 4.7 新增 IoTCardStore 方法:(通过 Model 层 getter/setter 辅助方法实现)
- [x] 4.8 新增 DeviceStore 方法UpdateRechargeTrackingFields
- [x] 4.9 运行 `lsp_diagnostics` 验证 Store 层无编译错误
## 5. Service 层更新 - 套餐管理
- [x] 5.1 更新 PackageService.Create校验虚流量配置启用时必填且 ≤ 真流量)
- [x] 5.2 更新 PackageService.Update同上校验逻辑
- [x] 5.3 更新 PackageService.List根据用户类型返回不同视角的成本价和一次性佣金金额
- [x] 5.4 新增辅助方法:获取代理的套餐分配关系并填充视角数据
- [x] 5.5 编写 PackageService 单元测试:虚流量配置校验场景
- [ ] 5.6 编写 PackageService 单元测试:代理视角套餐列表场景(需要完整测试环境)
## 6. Service 层更新 - 套餐分配
- [x] 6.1 更新 ShopPackageAllocationService.Create支持设置一次性佣金金额
- [x] 6.2 新增校验逻辑:一次性佣金金额 ≤ 上级能拿到的金额
- [x] 6.3 新增校验逻辑:一次性佣金金额 ≥ 0
- [x] 6.4 更新 ShopPackageAllocationService.Update支持修改一次性佣金金额
- [x] 6.5 编写 ShopPackageAllocationService 单元测试:一次性佣金金额校验场景
## 7. Service 层更新 - 系列管理与分配
- [x] 7.1 更新 PackageSeriesService支持一次性佣金规则配置的 CRUD
- [x] 7.2 简化 ShopSeriesAllocationService.Create移除完整一次性佣金配置的处理
- [x] 7.3 简化 ShopSeriesAllocationService.Update同上
- [x] 7.4 更新验证逻辑:从套餐系列获取一次性佣金规则进行校验
- [ ] 7.5 编写单元测试
## 8. Service 层更新 - 佣金计算
- [x] 8.1 重构一次性佣金触发逻辑:支持按系列追踪首充和累计充值状态
- [x] 8.2 实现链式分配计算逻辑:沿代理链向上计算各级代理分得的佣金
- [x] 8.3 更新累计充值逻辑:只有充值操作累计,直接购买不累计
- [x] 8.4 更新首充判断逻辑:从 `single_recharge` 改为 `first_recharge`
- [x] 8.5 实现一次性佣金时效检查:过期规则不触发返佣
- [x] 8.6 更新梯度佣金计算:支持 `stat_scope` 配置
- [x] 8.7 编写佣金计算 Service 单元测试:链式分配场景
- [x] 8.8 编写佣金计算 Service 单元测试:首充/累计充值场景
- [x] 8.9 编写佣金计算 Service 单元测试:梯度升级场景(已实现时效检查测试)
## 9. Service 层更新 - 强充检查
- [x] 9.1 更新首充强充金额计算max(首充要求, 套餐售价)
- [x] 9.2 新增累计充值强充金额计算:支持固定/动态两种模式
- [x] 9.3 更新强充流程:支持累计充值的累计逻辑
- [ ] 9.4 编写强充检查 Service 单元测试
## 10. Handler 层更新
- [x] 10.1 更新 PackageHandler适配新 DTO 结构
- [x] 10.2 更新 PackageSeriesHandler支持一次性佣金规则配置
- [x] 10.3 更新 ShopPackageAllocationHandler支持一次性佣金金额
- [x] 10.4 更新 ShopSeriesAllocationHandler简化请求/响应结构
- [x] 10.5 更新文档生成器 cmd/api/docs.go 和 cmd/gendocs/main.go
- [x] 10.6 运行 `lsp_diagnostics` 验证 Handler 层无编译错误
## 11. 集成测试
- [ ] 11.1 编写套餐 CRUD 集成测试:验证虚流量配置
- [ ] 11.2 编写套餐分配集成测试:验证一次性佣金金额
- [ ] 11.3 编写系列分配集成测试:验证简化后的配置
- [ ] 11.4 编写代理视角套餐列表集成测试
- [ ] 11.5 编写一次性佣金触发集成测试:首充场景
- [ ] 11.6 编写一次性佣金触发集成测试:累计充值场景
- [ ] 11.7 编写链式佣金分配集成测试
- [x] 11.8 运行全部测试确保通过
## 12. 验收
- [x] 12.1 运行完整测试套件,确保全部通过
- [x] 12.2 运行 `go build` 确保编译通过
- [ ] 12.3 本地环境功能验证:套餐创建/修改流程
- [ ] 12.4 本地环境功能验证:套餐分配流程
- [ ] 12.5 本地环境功能验证:一次性佣金触发流程
- [ ] 12.6 更新 OpenAPI 文档确认变更已反映

View File

@@ -0,0 +1,66 @@
# 共识文档
**Change**: refactor-one-time-commission-allocation
**确认时间**: 2026-02-04T11:50:00+08:00
**确认人**: 用户(通过 Question_tool 逐条确认)
---
## 1. 要做什么
- [x] 创建 `tb_shop_series_allocation` 新表,专门管理系列分配和一次性佣金(已确认)
- [x]`one_time_commission_amount``ShopPackageAllocation` 移到新表(已确认)
- [x] `ShopPackageAllocation` 精简为只管成本价,添加 `series_allocation_id` 关联(已确认)
- [x] `PackageSeries` 添加 `enable_one_time_commission` 布尔字段(提升到顶层)(已确认)
- [x] 梯度模式下也实现链式分配(代理能拿的金额 = min(梯度匹配金额, 上级给的上限))(已确认)
- [x] 删除未使用的 `ShopSeriesOneTimeCommissionTier` 表和相关代码(已确认)
- [x] 新增系列分配 APICRUD已确认
- [x] 业务流程改造:必须先分配系列,再分配套餐(已确认)
- [x] 佣金计算逻辑改为从系列分配获取佣金配置(已确认)
## 2. 不做什么
- [x] 不保留旧接口的兼容性(直接切换)(已确认)
- [x] 不支持代理自定义梯度规则(所有代理使用平台统一规则)(已确认)
- [x] 不在此次改造中修改前端交互流程(后续单独处理)(已确认)
## 3. 关键约束
- [x] 遵循项目技术栈规范Handler → Service → Store → Model已确认
- [x] 删除代码前必须确认无调用(`ShopSeriesOneTimeCommissionTier` 相关)(已确认)
- [x] `ShopSeriesCommissionStats``allocation_id` 需要重新关联到系列分配(已确认)
## 4. 验收标准
- [x] 同一 shop + series 只存在一条系列分配记录(唯一约束)(已确认)
- [x] 触发佣金时直接查询系列分配,不再有"取第一个"的 hack已确认
- [x] `enable_one_time_commission` 可通过 SQL WHERE 直接查询(已确认)
- [x] 分配套餐前必须先分配系列,否则报错(已确认)
- [x] 使用新工作流生成的验收测试和流程测试全部通过(已确认)
---
## 讨论背景
用户发现一次性佣金架构存在设计问题:
1. **概念与存储错位**:一次性佣金是"系列级"概念,但 `one_time_commission_amount` 存储在"套餐分配"`ShopPackageAllocation`)中
2. **数据冗余**:同一系列的多个套餐分配时,佣金配置需要重复设置
3. **隐性假设**:代码靠"取第一个"`GetByShopAndSeries` + `LIMIT 1`)来获取佣金,假设同系列配置相同但没有约束保证
4. **`enable` 藏在 JSON 里**:判断是否启用一次性佣金需要解析 JSON无法高效查询
5. **废弃代码**`ShopSeriesOneTimeCommissionTier` 表定义了但完全没有被使用
## 关键决策记录
| 决策点 | 选择 | 原因 |
|--------|------|------|
| 佣金存储位置 | 新建 `ShopSeriesAllocation` 表 | 职责分离:系列分配管佣金,套餐分配只管成本价 |
| 梯度模式下的分配 | 链式分配min(梯度匹配, 上级上限) | 保持与固定模式一致的业务逻辑 |
| 数据迁移 | 不做(开发阶段) | 现阶段无需迁移生产数据 |
| 旧接口兼容 | 不保留 | 简化实现,直接切换 |
| 代理自定义梯度 | 不支持 | 所有代理使用平台统一规则,简化配置 |
| 测试策略 | 使用新工作流验收测试 | 替代原有意义不大的测试 |
---
**签字确认**: 用户已通过 Question_tool 逐条确认以上内容

View File

@@ -0,0 +1,274 @@
# Design: refactor-one-time-commission-allocation
## Context
### 当前状态
一次性佣金配置分散在两个层级:
```
tb_package_series
├── one_time_commission_config (JSONB) ← 平台定义的规则(触发条件、梯度等)
│ └── { enable: true, trigger_type: "first_recharge", ... }
tb_shop_package_allocation
├── one_time_commission_amount ← 代理能拿的金额(但这是套餐级!)
└── series_id ← 冗余字段
```
问题:
1. 一个系列有多个套餐时,每个套餐分配都要重复设置佣金
2. 代码通过 `GetByShopAndSeries` + `LIMIT 1` 获取佣金,假设同系列配置相同
3. `enable` 藏在 JSON 里,无法索引查询
### 目标状态
```
tb_package_series
├── enable_one_time_commission (bool) ← 提升到顶层,可索引
├── one_time_commission_config (JSONB) ← 只存规则详情
tb_shop_series_allocation (新表)
├── shop_id + series_id (唯一) ← 一个店铺+系列只有一条记录
├── one_time_commission_amount ← 代理能拿的一次性佣金
tb_shop_package_allocation
├── series_allocation_id ← 关联系列分配
├── cost_price ← 只管成本价
└── (移除 one_time_commission_amount, series_id)
```
### 约束
- 遵循 Handler → Service → Store → Model 分层
- 禁止外键约束,通过 ID 字段手动关联
- 常量定义在 `pkg/constants/`
- 开发阶段,无需数据迁移
## Goals / Non-Goals
**Goals:**
- 职责分离:系列分配管一次性佣金,套餐分配只管成本价
- 数据不冗余:一个 shop + series 只有一条佣金配置
- 消除隐性假设:直接查询系列分配,无需"取第一个"
- 查询高效:`enable_one_time_commission` 可索引
**Non-Goals:**
- 不保留旧接口兼容性
- 不支持代理自定义梯度规则
- 不修改前端交互流程
- 不做数据迁移
## Decisions
### Decision 1: 新建 `ShopSeriesAllocation` 表而非修改现有表
**选择**: 新建 `tb_shop_series_allocation`
**备选方案**:
- A) 在 `ShopPackageAllocation` 中保留佣金,添加约束确保同系列一致
- B) 新建 `ShopSeriesAllocation` 表,专门管理系列级配置
**选择 B 的理由**:
- 职责单一:套餐分配只管成本价,系列分配只管佣金
- 数据模型清晰:一个 shop + series 对应一条记录,符合业务概念
- 避免复杂约束:方案 A 需要触发器或应用层约束保证一致性
### Decision 2: `enable_one_time_commission` 提升到顶层字段
**选择**: 在 `PackageSeries` 添加布尔字段 `enable_one_time_commission`
**备选方案**:
- A) 保留在 JSON 中,查询时解析
- B) 提升到顶层字段
**选择 B 的理由**:
- 可建索引:`WHERE enable_one_time_commission = true`
- 减少 JSON 解析开销
- 语义清晰:开关是开关,配置是配置
### Decision 3: 梯度模式下的链式分配
**选择**: 代理能拿的金额 = min(梯度匹配金额, 上级给的上限)
**备选方案**:
- A) 梯度模式下完全由销量决定,无上限约束
- B) 固定模式和梯度模式统一使用链式分配
**选择 B 的理由**:
- 业务一致性:不论哪种模式,上级都能控制下级的佣金上限
- 防止佣金倒挂:下级不可能拿到比上级给的更多
### Decision 4: 套餐分配依赖系列分配
**选择**: 分配套餐前必须先分配对应的系列
**实现方式**:
```go
// ShopPackageAllocationService.Create
func (s *Service) Create(ctx context.Context, req *dto.CreateRequest) error {
// 1. 获取套餐信息
pkg, _ := s.packageStore.GetByID(ctx, req.PackageID)
// 2. 检查系列分配是否存在
seriesAlloc, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, pkg.SeriesID)
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInvalidParam, "请先分配该套餐所属的系列")
}
// 3. 创建套餐分配,关联系列分配
allocation := &model.ShopPackageAllocation{
ShopID: req.ShopID,
PackageID: req.PackageID,
SeriesAllocationID: seriesAlloc.ID,
CostPrice: req.CostPrice,
// ...
}
return s.store.Create(ctx, allocation)
}
```
### Decision 5: 删除未使用的 `ShopSeriesOneTimeCommissionTier` 表
**选择**: 直接删除表和相关代码
**理由**:
- 经代码搜索确认完全未使用
- Store 方法未被调用
- 保留会造成混淆
## 架构设计
### 模块结构
```
internal/
├── model/
│ ├── shop_series_allocation.go # 新增
│ ├── shop_package_allocation.go # 修改
│ ├── package.go # 修改 PackageSeries
│ └── dto/
│ ├── shop_series_allocation.go # 新增
│ └── shop_package_allocation.go # 修改
├── store/postgres/
│ ├── shop_series_allocation_store.go # 新增
│ └── shop_package_allocation_store.go # 修改
├── service/
│ ├── shop_series_allocation/ # 新增
│ │ └── service.go
│ ├── shop_package_allocation/ # 修改
│ ├── commission_calculation/ # 修改
│ ├── recharge/ # 修改
│ └── order/ # 修改
├── handler/admin/
│ ├── shop_series_allocation.go # 新增
│ └── shop_package_allocation.go # 修改
└── bootstrap/
├── stores.go # 添加新 store
├── services.go # 添加新 service
└── handlers.go # 添加新 handler
```
### 依赖注入
```go
// bootstrap/stores.go
type Stores struct {
// 新增
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
// ...
}
// bootstrap/services.go
type Services struct {
// 新增
ShopSeriesAllocation *shop_series_allocation.Service
// ...
}
// 依赖关系
ShopSeriesAllocationService
ShopSeriesAllocationStore
PackageSeriesStore
ShopStore (获取下级店铺列表)
ShopPackageAllocationService
ShopPackageAllocationStore
ShopSeriesAllocationStore 新增依赖
PackageStore
ShopStore
```
### 事务处理
**删除系列分配时级联处理**:
```go
func (s *Service) Delete(ctx context.Context, id uint) error {
return s.store.Transaction(ctx, func(tx *gorm.DB) error {
// 1. 检查是否有关联的套餐分配
count, _ := s.packageAllocationStore.CountBySeriesAllocationID(ctx, id)
if count > 0 {
return errors.New(errors.CodeInvalidParam, "存在关联的套餐分配,无法删除")
}
// 2. 删除系列分配
return s.store.Delete(ctx, id)
})
}
```
### 常量定义
```go
// pkg/constants/redis.go
func RedisShopSeriesAllocationKey(shopID, seriesID uint) string {
return fmt.Sprintf("shop_series_alloc:%d:%d", shopID, seriesID)
}
// pkg/constants/constants.go
const (
// 系列分配状态
SeriesAllocationStatusEnabled = 1
SeriesAllocationStatusDisabled = 2
)
```
## Risks / Trade-offs
### Risk 1: 套餐分配 API 破坏性变更
**风险**: 移除 `one_time_commission_amount` 参数会破坏现有 API 调用
**缓解**:
- 开发阶段,无生产调用方
- 前端后续单独处理
### Risk 2: 佣金计算逻辑改动涉及多个 Service
**风险**: `commission_calculation``recharge``order` 都需要修改
**缓解**:
- 抽取公共方法:`GetSeriesAllocationForShop(shopID, seriesID)`
- 统一在 `ShopSeriesAllocationStore` 提供查询接口
### Risk 3: 删除代码可能遗漏引用
**风险**: `ShopSeriesOneTimeCommissionTier` 相关代码可能有遗漏引用
**缓解**:
- 删除前全局搜索确认
- 编译验证无引用
## Open Questions
1. **批量分配交互**:批量分配套餐时,如果系列未分配,是自动创建还是报错?
- 当前决定:报错,要求先分配系列
2. **系列分配的状态管理**:系列分配禁用后,已有的套餐分配如何处理?
- 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配

View File

@@ -0,0 +1,91 @@
# Proposal: refactor-one-time-commission-allocation
**Feature ID**: feature-012-refactor-one-time-commission-allocation
## Why
当前一次性佣金架构存在概念与存储错位的问题:一次性佣金是"系列级"概念(每张卡/设备在该系列下只触发一次),但 `one_time_commission_amount` 却存储在"套餐分配"`ShopPackageAllocation`)中。这导致:
1. **数据冗余**:同一系列的多个套餐分配时,佣金配置需要重复设置
2. **隐性假设**:代码靠"取第一个"`GetByShopAndSeries` + `LIMIT 1`)获取佣金,假设同系列配置相同但无约束保证
3. **查询低效**`enable` 藏在 JSON 里,无法高效查询
4. **废弃代码**`ShopSeriesOneTimeCommissionTier` 表定义了但完全未使用
## What Changes
### 新增
- 创建 `tb_shop_series_allocation` 新表,专门管理系列分配和一次性佣金
- 新增系列分配 APICRUD`/api/admin/shop-series-allocations`
- `PackageSeries` 添加 `enable_one_time_commission` 布尔字段(从 JSON 提升到顶层)
### 修改
- **BREAKING** `ShopPackageAllocation` 移除 `one_time_commission_amount``series_id` 字段,添加 `series_allocation_id` 关联
- 佣金计算逻辑改为从系列分配获取佣金配置
- 梯度模式下实现链式分配:代理能拿的金额 = min(梯度匹配金额, 上级给的上限)
- 业务流程改造:必须先分配系列,再分配套餐
- `ShopSeriesCommissionStats``allocation_id` 重新关联到系列分配
### 删除
- 删除 `ShopSeriesOneTimeCommissionTier` 表和相关代码(从未使用)
- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法("取第一个"hack
## Capabilities
### New Capabilities
- `shop-series-allocation`: 店铺系列分配模块,管理店铺对套餐系列的分配关系和一次性佣金配置
### Modified Capabilities
- `commission-calculation`: 佣金计算改用系列分配获取一次性佣金配置,梯度模式实现链式分配
- `commission-trigger`: 佣金触发时从系列分配读取佣金金额
## Impact
### 代码影响
| 模块 | 影响 |
|------|------|
| `internal/model/` | 新增 `ShopSeriesAllocation`,修改 `ShopPackageAllocation``PackageSeries` |
| `internal/store/postgres/` | 新增 `shop_series_allocation_store.go`,修改套餐分配和佣金相关 store |
| `internal/service/` | 新增 `shop_series_allocation/`,修改 `commission_calculation/``recharge/``order/` |
| `internal/handler/admin/` | 新增 `shop_series_allocation.go`,修改套餐分配 handler |
| `internal/router/` | 添加系列分配路由 |
### API 影响
| 类型 | 端点 | 说明 |
|------|------|------|
| 新增 | `POST /api/admin/shop-series-allocations` | 创建系列分配 |
| 新增 | `GET /api/admin/shop-series-allocations` | 查询系列分配列表 |
| 新增 | `GET /api/admin/shop-series-allocations/:id` | 获取系列分配详情 |
| 新增 | `PUT /api/admin/shop-series-allocations/:id` | 更新系列分配 |
| 新增 | `DELETE /api/admin/shop-series-allocations/:id` | 删除系列分配 |
| **BREAKING** | `POST /api/admin/shop-package-allocations` | 移除 `one_time_commission_amount` 参数 |
| **BREAKING** | `PUT /api/admin/shop-package-allocations/:id` | 移除 `one_time_commission_amount` 参数 |
### 数据库影响
- 新增表:`tb_shop_series_allocation`
- 修改表:`tb_shop_package_allocation`(删除列)、`tb_package_series`(新增列)
- 删除表:`tb_shop_series_one_time_commission_tier`
### 技术栈
- 遵循 Handler → Service → Store → Model 分层架构
- 使用 Fiber v2.x + GORM v1.25.x
- 使用新工作流生成验收测试和流程测试
### 测试计划
- 使用 `/opsx:gen-tests` 从 Spec 生成验收测试
- 覆盖系列分配 CRUD、佣金计算链式分配、业务流程约束
- 删除原有相关测试,使用新工作流测试替代
### 性能考虑
- 系列分配查询:直接通过 `shop_id + series_id` 唯一索引查询,无需 LIMIT 1
- `enable_one_time_commission` 字段可建索引,支持高效过滤

View File

@@ -0,0 +1,147 @@
# Delta Spec: commission-calculation
## MODIFIED Requirements
### Requirement: 一次性佣金触发检查
系统 SHALL 在更新累计充值金额后立即检查是否触发一次性佣金。**一次性佣金金额从系列分配获取,而非套餐分配**。
**关键变更**
- 原来从 `ShopPackageAllocation.one_time_commission_amount` 获取佣金金额
- 现在从 `ShopSeriesAllocation.one_time_commission_amount` 获取佣金金额
- 梯度模式下采用链式分配:实际金额 = min(梯度匹配金额, 系列分配的上限)
#### Scenario: 累计达到阈值触发佣金(固定模式)
- **WHEN** 更新累计充值后,累计值 >= 配置阈值
- **AND** 卡/设备的 first_commission_paid = false
- **AND** 系列配置为固定模式type = "fixed"
- **THEN** 系统从该卡/设备销售代理的系列分配获取 one_time_commission_amount
- **AND** 发放该金额作为一次性佣金
- **AND** 标记 first_commission_paid = true
#### Scenario: 累计达到阈值触发佣金(梯度模式)
- **WHEN** 更新累计充值后,累计值 >= 配置阈值
- **AND** 卡/设备的 first_commission_paid = false
- **AND** 系列配置为梯度模式type = "tiered"
- **AND** 代理销售数量为 50 张梯度配置1-30张80元31-100张100元
- **AND** 代理的系列分配 one_time_commission_amount = 900090元上限
- **THEN** 梯度匹配金额 = 100 元50张落在31-100档
- **AND** 实际发放金额 = min(100, 90) = 90 元
- **AND** 标记 first_commission_paid = true
#### Scenario: 累计未达到阈值不触发
- **WHEN** 更新累计充值后,累计值 < 配置阈值
- **THEN** 系统不发放一次性佣金
- **AND** first_commission_paid 保持不变
#### Scenario: 已发放过不重复触发
- **WHEN** 更新累计充值后,累计值 >= 配置阈值
- **AND** 卡/设备的 first_commission_paid = true
- **THEN** 系统不重复发放一次性佣金
---
### Requirement: 一次性佣金链式分配
系统 SHALL 为代理链上的每一级代理计算一次性佣金差价收入。每级代理的收入 = 自己的分配上限 - 下级的分配上限。
#### Scenario: 单级代理
- **WHEN** 一级代理销售卡,系列分配 one_time_commission_amount = 10000100元
- **AND** 无下级(终端销售)
- **THEN** 一级代理获得 100 元一次性佣金
#### Scenario: 多级代理链式分配
- **WHEN** 三级代理销售卡,各级系列分配:
- 平台给一级one_time_commission_amount = 10000100元
- 一级给二级one_time_commission_amount = 800080元
- 二级给三级one_time_commission_amount = 500050元
- **THEN** 三级获得 50 元
- **AND** 二级获得 30 元80 - 50
- **AND** 一级获得 20 元100 - 80
#### Scenario: 梯度模式下的链式分配
- **WHEN** 系列配置为梯度模式,梯度匹配金额为 120 元
- **AND** 三级代理的系列分配上限为 50 元,二级为 80 元,一级为 100 元
- **THEN** 三级获得 min(120, 50) = 50 元
- **AND** 二级获得 min(120, 80) - 50 = 30 元
- **AND** 一级获得 min(120, 100) - 80 = 20 元
- **AND** 平台获得 120 - 100 = 20 元
#### Scenario: 某级差价为零
- **WHEN** 某级代理分配的上限等于下级
- **THEN** 该级代理一次性佣金差价为 0不创建佣金记录
---
### Requirement: 钱包充值触发一次性佣金
钱包充值成功后 SHALL 更新累计充值,并检查是否触发一次性佣金。**佣金金额从系列分配获取**。
#### Scenario: 充值成功更新累计充值
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
- **THEN** 系统更新卡的 accumulated_recharge 为 300 元
#### Scenario: 充值达到首次充值阈值
- **WHEN** 卡配置为首次充值触发,阈值 100 元,充值 100 元成功,未发放过佣金
- **THEN** 系统从该卡销售代理的系列分配获取 one_time_commission_amount
- **AND** 触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
#### Scenario: 充值达到累计充值阈值
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,充值后累计达到 1000 元,未发放过佣金
- **THEN** 系统从该卡销售代理的系列分配获取 one_time_commission_amount
- **AND** 触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
#### Scenario: 充值未达阈值不触发
- **WHEN** 充值后累计充值未达到阈值
- **THEN** 系统不触发一次性佣金计算
#### Scenario: 已发放过不重复触发
- **WHEN** 卡的一次性佣金已发放过first_commission_paid = true
- **THEN** 系统不触发一次性佣金计算
---
## ADDED Requirements
### Requirement: 系列分配查询优化
系统 SHALL 提供通过店铺和系列直接查询系列分配的方法,替代原有的"取第一个"逻辑。
#### Scenario: 直接查询系列分配
- **WHEN** 需要获取代理的一次性佣金配置
- **THEN** 系统通过 shop_id + series_id 直接查询 ShopSeriesAllocation
- **AND** 返回唯一匹配的记录(唯一索引保证)
#### Scenario: 系列分配不存在
- **WHEN** 查询的 shop_id + series_id 组合不存在分配记录
- **THEN** 系统返回 "未找到系列分配配置" 错误
- **AND** 不发放一次性佣金(但不影响成本价差收入计算)
---
### Requirement: enable_one_time_commission 字段查询
系统 SHALL 从 PackageSeries 的顶层字段读取一次性佣金开关状态,支持 SQL 索引查询。
#### Scenario: 检查系列是否启用一次性佣金
- **WHEN** 需要判断是否触发一次性佣金
- **THEN** 系统查询 PackageSeries.enable_one_time_commission 字段
- **AND** 不再解析 one_time_commission_config JSON
#### Scenario: 批量查询启用一次性佣金的系列
- **WHEN** 需要统计启用一次性佣金的系列数量
- **THEN** 系统可使用 `WHERE enable_one_time_commission = true` 直接查询
- **AND** 无需 JSON 解析
---
## REMOVED Requirements
### Requirement: 从套餐分配获取一次性佣金金额
**Reason**: 一次性佣金是系列级概念,应从系列分配获取,而非套餐分配
**Migration**:
- 原查询路径:`ShopPackageAllocation.one_time_commission_amount`
- 新查询路径:`ShopSeriesAllocation.one_time_commission_amount`
- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法

View File

@@ -0,0 +1,81 @@
# Delta Spec: commission-trigger
## MODIFIED Requirements
### Requirement: 佣金计算任务幂等性
系统 SHALL 确保佣金计算任务可重复执行,不重复发放佣金。**一次性佣金从系列分配获取金额**。
**关键变更**
- 任务执行时,一次性佣金金额从 `ShopSeriesAllocation` 获取
- 不再依赖 `ShopPackageAllocation.one_time_commission_amount`
#### Scenario: 任务重复执行跳过计算
- **WHEN** 佣金计算任务执行时,订单 `commission_status` 已为 `calculated`
- **THEN** 系统跳过佣金计算和钱包入账操作
- **AND** 任务返回成功(避免 Asynq 重试)
- **AND** 日志记录"订单佣金已计算,跳过执行"
#### Scenario: 并发任务只有一个成功
- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行
- **THEN** 第一个任务成功完成计算并更新状态为 `calculated`
- **AND** 第二个任务检查到状态已为 `calculated`,跳过计算
#### Scenario: 任务失败可安全重试
- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用)
- **THEN** Asynq 自动重试任务
- **AND** 重试时幂等检查确保不重复发放佣金
---
## ADDED Requirements
### Requirement: 佣金计算时查询系列分配
系统 SHALL 在佣金计算任务中通过系列分配获取一次性佣金配置。
#### Scenario: 获取销售代理的系列分配
- **WHEN** 佣金计算任务执行,需要计算一次性佣金
- **THEN** 系统根据订单的卡/设备找到销售代理shop_id
- **AND** 根据套餐找到系列series_id
- **AND** 查询 ShopSeriesAllocation(shop_id, series_id) 获取 one_time_commission_amount
#### Scenario: 系列分配不存在时处理
- **WHEN** 佣金计算任务执行,但找不到对应的系列分配
- **THEN** 系统记录警告日志 "未找到系列分配,跳过一次性佣金"
- **AND** 继续计算成本价差收入(不因此失败)
- **AND** 订单 commission_status 正常更新为 calculated
#### Scenario: 获取代理链的系列分配
- **WHEN** 需要计算一次性佣金的链式分配
- **THEN** 系统沿代理链向上查询每级代理的系列分配
- **AND** 计算每级的差价收入
---
### Requirement: CommissionStats 关联系列分配
系统 SHALL 将 ShopSeriesCommissionStats 的 allocation_id 关联到系列分配,而非套餐分配。
#### Scenario: 创建佣金统计记录
- **WHEN** 发放一次性佣金后更新统计
- **THEN** CommissionStats.allocation_id 指向 ShopSeriesAllocation.id
- **AND** 不再指向 ShopPackageAllocation.id
#### Scenario: 查询店铺的系列佣金统计
- **WHEN** 查询某店铺在某系列的佣金统计
- **THEN** 通过 ShopSeriesAllocation.id 关联查询
- **AND** 统计包括该系列下所有套餐产生的一次性佣金
---
## REMOVED Requirements
### Requirement: 从套餐分配读取佣金金额
**Reason**: 一次性佣金配置迁移到系列分配
**Migration**:
- 原逻辑:通过 `GetByShopAndSeries` 查询套餐分配,取第一条的 `one_time_commission_amount`
- 新逻辑:直接查询 `ShopSeriesAllocation(shop_id, series_id)`
- 删除 `ShopPackageAllocationStore.GetByShopAndSeries` 方法

View File

@@ -0,0 +1,166 @@
# Delta Spec: shop-series-allocation
## MODIFIED Requirements
### Requirement: 为下级店铺分配套餐系列
系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定**一次性佣金金额上限**代替原来的基础返佣配置MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。
**关键变更**
- 移除 `commission_mode``commission_value` 字段(基础返佣配置)
- 新增 `one_time_commission_amount` 字段:代理能拿的一次性佣金金额上限(分)
- 一次性佣金计算采用**链式分配**:代理实际获得金额 = min(系列配置的梯度/固定金额, 上级给的上限)
**API 接口变更**
- 移除请求/响应中的 `commission_mode``commission_value` 字段
- 新增 `one_time_commission_amount` 字段(分,必填)
#### Scenario: 成功分配套餐系列
- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置 one_time_commission_amount = 500050元
- **THEN** 系统创建分配记录,下级代理能拿的一次性佣金上限为 50 元
#### Scenario: 链式分配金额计算
- **WHEN** 平台给一级代理分配系列one_time_commission_amount = 10000100元
- **AND** 一级代理给二级代理分配one_time_commission_amount = 800080元
- **AND** 二级代理给三级代理分配one_time_commission_amount = 500050元
- **THEN** 三级代理能拿的一次性佣金上限为 50 元
- **AND** 二级代理差价 = 80 - 50 = 30 元
- **AND** 一级代理差价 = 100 - 80 = 20 元
#### Scenario: 下级金额不能超过上级
- **WHEN** 代理尝试为下级分配,设置的 one_time_commission_amount 超过自己被分配的金额
- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限"
#### Scenario: 分配时启用一次性佣金和强充
- **WHEN** 代理为下级分配系列one_time_commission_amount = 5000启用一次性佣金触发类型为累计充值阈值 1000001000元启用强充强充金额 10000100元
- **THEN** 系统保存配置one_time_commission_amount = 5000enable_one_time_commission = truetrigger = "accumulated_recharge"threshold = 100000enable_force_recharge = trueforce_recharge_amount = 10000
#### Scenario: 尝试分配未拥有的系列
- **WHEN** 代理尝试分配自己未被分配的套餐系列
- **THEN** 系统返回错误 "您没有该套餐系列的分配权限"
#### Scenario: 尝试分配给非直属下级
- **WHEN** 代理尝试分配给非直属下级店铺
- **THEN** 系统返回错误 "只能为直属下级分配套餐"
#### Scenario: 重复分配同一系列
- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列
- **THEN** 系统返回错误 "该店铺已分配此套餐系列"
---
### Requirement: 查询套餐系列分配列表
系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。**响应 MUST 包含 one_time_commission_amount 字段**。
#### Scenario: 查询所有分配
- **WHEN** 代理查询分配列表,不带筛选条件
- **THEN** 系统返回该代理创建的所有分配记录,每条记录包含 one_time_commission_amount 字段
#### Scenario: 按店铺筛选
- **WHEN** 代理指定下级店铺 ID 筛选
- **THEN** 系统只返回该店铺的分配记录,记录包含 one_time_commission_amount 字段
#### Scenario: 响应包含一次性佣金金额
- **WHEN** 查询分配列表或详情
- **THEN** 每条记录包含 one_time_commission_amount 字段(分)
---
### Requirement: 更新套餐系列分配
系统 SHALL 允许代理更新分配的一次性佣金金额、一次性佣金配置和强充配置。**API 请求 MUST 支持更新 one_time_commission_amount 字段**。
#### Scenario: 更新一次性佣金金额
- **WHEN** 代理将 one_time_commission_amount 从 5000 改为 6000
- **THEN** 系统更新分配记录,后续一次性佣金按新金额计算
#### Scenario: 更新金额不能超过上级上限
- **WHEN** 代理尝试将 one_time_commission_amount 更新为超过自己被分配上限的值
- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限"
#### Scenario: 更新强充配置
- **WHEN** 代理将 enable_force_recharge 从 false 改为 true设置 force_recharge_amount = 10000
- **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求
#### Scenario: 更新不存在的分配
- **WHEN** 代理更新不存在的分配 ID
- **THEN** 系统返回 "分配记录不存在" 错误
---
### Requirement: 平台分配套餐系列
平台管理员 SHALL 能够为一级代理分配套餐系列,指定一次性佣金金额上限。平台作为分配链顶端,其金额上限由系列配置决定。
#### Scenario: 平台为一级代理分配
- **WHEN** 平台管理员为一级代理分配套餐系列,设置 one_time_commission_amount = 10000100元
- **THEN** 系统创建分配记录,一级代理能拿的一次性佣金上限为 100 元
#### Scenario: 平台金额不能超过系列配置
- **WHEN** 套餐系列配置的一次性佣金固定金额为 15000150元
- **AND** 平台尝试为一级代理分配one_time_commission_amount = 20000200元
- **THEN** 系统返回错误 "一次性佣金金额不能超过系列配置上限"
#### Scenario: 平台配置强充要求
- **WHEN** 平台为一级代理分配系列启用强充force_recharge_amount = 10000
- **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求
---
## ADDED Requirements
### Requirement: 套餐分配依赖系列分配
系统 SHALL 要求在分配套餐给下级店铺之前,必须先分配对应的套餐系列。
#### Scenario: 先分配系列再分配套餐
- **WHEN** 代理尝试为下级分配套餐 A属于系列 X
- **AND** 下级店铺已被分配系列 X
- **THEN** 系统允许创建套餐分配,并关联到系列分配记录
#### Scenario: 未分配系列时分配套餐失败
- **WHEN** 代理尝试为下级分配套餐 A属于系列 X
- **AND** 下级店铺未被分配系列 X
- **THEN** 系统返回错误 "请先分配该套餐所属的系列"
#### Scenario: 删除系列分配时检查套餐分配
- **WHEN** 代理尝试删除系列分配
- **AND** 存在依赖该系列分配的套餐分配
- **THEN** 系统返回错误 "存在关联的套餐分配,无法删除"
---
### Requirement: 套餐分配精简
套餐分配ShopPackageAllocationSHALL 只管理成本价,一次性佣金配置移到系列分配。
#### Scenario: 套餐分配只包含成本价
- **WHEN** 创建套餐分配
- **THEN** 请求/响应只包含 cost_price 字段
- **AND** 不包含 one_time_commission_amount 字段
#### Scenario: 套餐分配关联系列分配
- **WHEN** 创建套餐分配
- **THEN** 系统自动关联对应的系列分配(通过 series_allocation_id
---
## REMOVED Requirements
### Requirement: 梯度返佣配置
**Reason**: 已在之前版本移除,此处确认删除状态
**Migration**: 使用一次性佣金的梯度模式替代
---
### Requirement: 基础返佣配置
**Reason**: commission_mode 和 commission_value 字段被 one_time_commission_amount 替代
**Migration**:
- 旧字段 commission_mode百分比/固定值)和 commission_value 移除
- 新字段 one_time_commission_amount表示代理能拿的一次性佣金上限
- 成本价差收入的计算不变,仍从套餐分配的 cost_price 计算

View File

@@ -0,0 +1,174 @@
# Tasks: refactor-one-time-commission-allocation
## 0. 测试准备(实现前执行)
- [x] 0.1 生成验收测试和流程测试
- 运行 `/opsx:gen-tests refactor-one-time-commission-allocation`
- 确认生成文件:`tests/acceptance/shop_series_allocation_acceptance_test.go`
- 确认生成文件:`tests/acceptance/commission_calculation_acceptance_test.go`
- [x] 0.2 运行测试确认全部 FAIL
- `source .env.local && go test -v ./tests/acceptance/... -run ShopSeriesAllocation`
- `source .env.local && go test -v ./tests/acceptance/... -run CommissionCalculation`
- 预期:全部 FAIL功能未实现证明测试有效
## 1. 数据库迁移
- [x] 1.1 创建迁移:新增 `tb_shop_series_allocation`
- 字段id, created_at, updated_at, deleted_at, creator, updater
- 字段shop_id, series_id, allocator_shop_id, one_time_commission_amount, status
- 字段enable_one_time_commission, one_time_commission_trigger, one_time_commission_threshold
- 字段enable_force_recharge, force_recharge_amount, force_recharge_trigger_type
- 唯一索引:(shop_id, series_id)
- [x] 1.2 创建迁移:修改 `tb_package_series`
- 新增字段enable_one_time_commission (bool, 默认 false)
- [x] 1.3 创建迁移:修改 `tb_shop_package_allocation`
- 新增字段series_allocation_id (uint)
- 删除字段one_time_commission_amount, series_id
- [x] 1.4 创建迁移:删除 `tb_shop_series_one_time_commission_tier`
- [x] 1.5 执行迁移并验证
- `source .env.local && ./scripts/migrate.sh up`
- 验证:表结构正确
## 2. Model 层
- [x] 2.1 创建 `internal/model/shop_series_allocation.go`
- ShopSeriesAllocation 结构体 + TableName()
- [x] 2.2 创建 `internal/model/dto/shop_series_allocation.go`
- Create/Update Request, Response, ListRequest
- [x] 2.3 修改 `internal/model/package.go`
- PackageSeries 添加 EnableOneTimeCommission 字段
- [x] 2.4 修改 `internal/model/shop_package_allocation.go`
- 删除 OneTimeCommissionAmount, SeriesID
- 添加 SeriesAllocationID
- [x] 2.5 修改 `internal/model/dto/shop_package_allocation.go`
- 删除 one_time_commission_amount 字段
- [x] 2.6 验证 Model 层
- `lsp_diagnostics` 检查所有修改的文件
- `go build ./internal/model/...`
## 3. 系列分配功能(完整功能单元)
- [x] 3.1 创建 `internal/store/postgres/shop_series_allocation_store.go`
- Create, Update, Delete, GetByID
- GetByShopAndSeries(shopID, seriesID)
- List支持筛选
- CountBySeriesID
- [x] 3.2 创建 `internal/service/shop_series_allocation/service.go`
- Create: 验证上级分配、金额上限
- Update: 验证金额上限
- Delete: 检查套餐分配依赖
- Get, List
- [x] 3.3 创建 `internal/handler/admin/shop_series_allocation.go`
- POST /api/admin/shop-series-allocations
- GET /api/admin/shop-series-allocations
- GET /api/admin/shop-series-allocations/:id
- PUT /api/admin/shop-series-allocations/:id
- DELETE /api/admin/shop-series-allocations/:id
- [x] 3.4 添加路由和 Bootstrap
- 路由注册
- stores.go 添加 ShopSeriesAllocationStore
- services.go 添加 ShopSeriesAllocationService
- handlers.go 添加 ShopSeriesAllocationHandler
- [x] 3.5 更新文档生成器
- pkg/openapi/handlers.go 添加 Handler
- [x] 3.6 **验证:系列分配验收测试 PASS**
- `source .env.local && go test -v ./tests/acceptance/... -run ShopSeriesAllocation`
- ✅ 所有系列分配 CRUD 相关测试全部 PASS
## 4. 套餐分配改造
- [x] 4.1 修改 `internal/store/postgres/shop_package_allocation_store.go`
- 删除 GetByShopAndSeries 方法
- 添加 CountBySeriesAllocationID 方法
- 更新 Create/Update 适配新字段
- [x] 4.2 修改 `internal/service/shop_package_allocation/service.go`
- Create: 添加系列分配依赖检查
- Create: 自动关联 series_allocation_id
- 移除 one_time_commission_amount 逻辑
- [x] 4.3 修改 `internal/handler/admin/shop_package_allocation.go`
- 移除请求/响应中的 one_time_commission_amount
- [x] 4.4 **验证:套餐分配依赖检查测试 PASS**
- `source .env.local && go test -v ./tests/acceptance/... -run ShopPackageAllocation_SeriesDependency`
- ✅ 测试通过
## 5. 佣金计算改造
- [x] 5.1 修改 `internal/service/commission_calculation/service.go`
- 一次性佣金从 ShopSeriesAllocation 获取
- 实现链式分配计算
- 梯度模式min(梯度匹配金额, 分配上限)
- [x] 5.2 修改 `internal/service/recharge/service.go`
- 充值触发一次性佣金从系列分配获取配置
- [x] 5.3 修改佣金统计相关代码
- CommissionStats.allocation_id 关联到系列分配
- [x] 5.4 **验证:佣金计算验收测试 PASS**
- `source .env.local && go test -v ./tests/acceptance/... -run CommissionCalculation`
- ✅ 所有佣金计算测试通过
## 6. 清理废弃代码
- [x] 6.1 删除 ShopSeriesOneTimeCommissionTier 相关代码
- 删除 model 文件
- 删除 store 文件
- 删除 bootstrap 中的引用
- [x] 6.2 全局搜索并清理遗留引用
- 搜索 "one_time_commission_amount" 在 ShopPackageAllocation 的使用
- 搜索 "GetByShopAndSeries" 的调用
- 更新所有引用点
- [x] 6.3 验证清理完成
- `go build ./...` 编译通过
## 7. 常量定义
- [x] 7.1 更新 `pkg/constants/redis.go`
- 添加 RedisShopSeriesAllocationKey 函数
- [x] 7.2 更新 `pkg/constants/constants.go`
- 使用已有的通用常量 StatusEnabled/StatusDisabled (值 1/0)
## 8. 最终验证
- [x] 8.1 运行所有验收测试
- `source .env.local && go test -v ./tests/acceptance/...`
- ✅ 全部 PASS
- [x] 8.2 运行流程测试
- `source .env.local && go test -v ./tests/flows/...`
- ✅ 全部 PASS
- [x] 8.3 运行完整测试套件
- `source .env.local && go test -v ./...`
- ✅ 验收测试和流程测试全部 PASS
- ⚠️ 预先存在的失败与本次重构无关gateway/account/store 等测试
- [x] 8.4 编译和启动验证
- `go build ./...` ✅ 编译通过
- `source .env.local && go run cmd/api/main.go`
- 验证:服务正常启动
- [x] 8.5 重新生成 OpenAPI 文档
- `go run cmd/gendocs/main.go`
- 验证:文档包含新接口 ✅ `/api/admin/shop-series-allocations`