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,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. **前端发版协调**:是否需要灰度发布?
- 取决于前端改动量,建议同步上线