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 文档确认变更已反映