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) - 删除过时的单元测试(已被验收测试覆盖)
16 KiB
16 KiB
Context
背景
当前套餐与佣金系统在快速迭代中积累了技术债务,主要问题:
- 套餐价格字段混乱:
Price、SuggestedCostPrice、SuggestedRetailPrice三个字段语义不清,不同场景使用不一致 - 流量字段设计缺陷:
DataType暗示真/虚流量二选一,但业务需求是共存机制 - 分配层次过多:存在
ShopSeriesAllocation(系列分配)和ShopPackageAllocation(套餐分配)两层,但业务模型只需要一层 - 差价佣金计算复杂:使用
BaseCommissionMode/Value动态计算,但业务模型是简单的成本价差值 - 一次性佣金配置位置错误:配置在系列分配表中,应该只在套餐分配表中存储金额
业务模型(Source of Truth)
详见 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
- 简化套餐模型:只保留
cost_price+suggested_retail_price,语义清晰 - 支持流量共存:真流量必填 + 虚流量可选开关,停机判断逻辑统一
- 删除 ShopSeriesAllocation:移除多余的分配层次
- 简化差价佣金计算:直接使用成本价差值,删除动态计算逻辑
- 统一分配模型:一次性佣金金额只存储在
ShopPackageAllocation中 - 代理视角隔离:不同代理看到自己的成本价和能拿到的一次性佣金
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.gointernal/model/shop_series_allocation_config.gointernal/model/dto/shop_series_allocation.gointernal/store/postgres/shop_series_allocation_store.gointernal/store/postgres/shop_series_allocation_config_store.gointernal/service/shop_series_allocation/service.gointernal/handler/admin/shop_series_allocation.gointernal/routes/shop_series_allocation.gopkg/utils/commission.go(CalculateCostPrice 函数)
理由:
- 业务模型只需要一层分配(套餐分配)
- 系列分配增加了不必要的复杂度
- 所有需要的信息都可以在套餐分配中存储
D2: 修改 ShopPackageAllocation 模型
决策:扩展 ShopPackageAllocation 以承担原 ShopSeriesAllocation 的职责
// 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: 差价佣金计算简化
决策:删除动态计算逻辑,使用固定成本价差值
// 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
// 套餐系列定义规则(不变)
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,保留并重命名字段
// 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 获取数据
// 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 模型中新增追踪字段
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
注意:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。
数据库变更
-- 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;
代码变更顺序
-
Model 层
- 修改
ShopPackageAllocation模型 - 删除
ShopSeriesAllocation相关模型
- 修改
-
DTO 层
- 修改
ShopPackageAllocationDTO - 删除
ShopSeriesAllocationDTO
- 修改
-
Store 层
- 修改
ShopPackageAllocationStore - 删除
ShopSeriesAllocationStore
- 修改
-
Service 层
- 修改所有依赖
ShopSeriesAllocation的 Service - 删除
ShopSeriesAllocationService
- 修改所有依赖
-
Handler/Routes 层
- 删除
ShopSeriesAllocationHandler - 删除相关路由注册
- 删除
-
Bootstrap 层
- 移除所有
ShopSeriesAllocation相关初始化
- 移除所有
Open Questions
-
历史订单佣金:已完成的订单佣金是否需要按新规则重算?
- 建议:不重算,保持历史数据稳定
-
过渡期时长:新旧字段并存多久?
- 建议:2周观察期,确认无问题后清理
-
前端发版协调:是否需要灰度发布?
- 取决于前端改动量,建议同步上线