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