refactor: 一次性佣金配置从套餐级别提升到系列级别
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-03
|
||||
@@ -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成本价: 130(A分配给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. **前端发版协调**:是否需要灰度发布?
|
||||
- 取决于前端改动量,建议同步上线
|
||||
@@ -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` 一次性佣金配置需要迁移到新结构
|
||||
@@ -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 不再触发一次性佣金
|
||||
@@ -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
|
||||
@@ -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元
|
||||
@@ -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元)
|
||||
@@ -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元
|
||||
@@ -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`(首充)
|
||||
@@ -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 从零开始
|
||||
@@ -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`(虚流量)明确区分
|
||||
@@ -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** 返回完整的一次性佣金规则配置
|
||||
@@ -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
|
||||
@@ -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` 字段
|
||||
@@ -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` 迁移到新字段
|
||||
@@ -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 文档确认变更已反映
|
||||
@@ -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] 新增系列分配 API(CRUD)(已确认)
|
||||
- [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 逐条确认以上内容
|
||||
@@ -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. **系列分配的状态管理**:系列分配禁用后,已有的套餐分配如何处理?
|
||||
- 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配
|
||||
@@ -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` 新表,专门管理系列分配和一次性佣金
|
||||
- 新增系列分配 API(CRUD):`/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` 字段可建索引,支持高效过滤
|
||||
@@ -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 = 9000(90元上限)
|
||||
- **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 = 10000(100元)
|
||||
- **AND** 无下级(终端销售)
|
||||
- **THEN** 一级代理获得 100 元一次性佣金
|
||||
|
||||
#### Scenario: 多级代理链式分配
|
||||
- **WHEN** 三级代理销售卡,各级系列分配:
|
||||
- 平台给一级:one_time_commission_amount = 10000(100元)
|
||||
- 一级给二级:one_time_commission_amount = 8000(80元)
|
||||
- 二级给三级:one_time_commission_amount = 5000(50元)
|
||||
- **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` 方法
|
||||
@@ -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` 方法
|
||||
@@ -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 = 5000(50元)
|
||||
- **THEN** 系统创建分配记录,下级代理能拿的一次性佣金上限为 50 元
|
||||
|
||||
#### Scenario: 链式分配金额计算
|
||||
- **WHEN** 平台给一级代理分配系列,one_time_commission_amount = 10000(100元)
|
||||
- **AND** 一级代理给二级代理分配,one_time_commission_amount = 8000(80元)
|
||||
- **AND** 二级代理给三级代理分配,one_time_commission_amount = 5000(50元)
|
||||
- **THEN** 三级代理能拿的一次性佣金上限为 50 元
|
||||
- **AND** 二级代理差价 = 80 - 50 = 30 元
|
||||
- **AND** 一级代理差价 = 100 - 80 = 20 元
|
||||
|
||||
#### Scenario: 下级金额不能超过上级
|
||||
- **WHEN** 代理尝试为下级分配,设置的 one_time_commission_amount 超过自己被分配的金额
|
||||
- **THEN** 系统返回错误 "一次性佣金金额不能超过您的分配上限"
|
||||
|
||||
#### Scenario: 分配时启用一次性佣金和强充
|
||||
- **WHEN** 代理为下级分配系列,one_time_commission_amount = 5000,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元)
|
||||
- **THEN** 系统保存配置:one_time_commission_amount = 5000,enable_one_time_commission = true,trigger = "accumulated_recharge",threshold = 100000,enable_force_recharge = true,force_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 = 10000(100元)
|
||||
- **THEN** 系统创建分配记录,一级代理能拿的一次性佣金上限为 100 元
|
||||
|
||||
#### Scenario: 平台金额不能超过系列配置
|
||||
- **WHEN** 套餐系列配置的一次性佣金固定金额为 15000(150元)
|
||||
- **AND** 平台尝试为一级代理分配,one_time_commission_amount = 20000(200元)
|
||||
- **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: 套餐分配精简
|
||||
|
||||
套餐分配(ShopPackageAllocation)SHALL 只管理成本价,一次性佣金配置移到系列分配。
|
||||
|
||||
#### 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 计算
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user