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,444 @@
package acceptance
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================
// 验收测试:佣金计算重构
// 来源openspec/changes/refactor-one-time-commission-allocation/specs/commission-calculation/spec.md
// 来源openspec/changes/refactor-one-time-commission-allocation/specs/commission-trigger/spec.md
// ============================================================
func TestCommissionCalculation_SeriesAllocationQuery_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createCommissionTestSeries(t, env, "佣金测试系列")
createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000)
createSeriesAllocationForCommission(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
// ------------------------------------------------------------
// Scenario: 直接查询系列分配
// GIVEN: 存在 shop_id + series_id 的系列分配记录
// WHEN: 通过 shop_id 和 series_id 查询
// THEN: 返回唯一匹配的记录,包含 one_time_commission_amount
//
// 破坏点:如果查询 API 不支持 series_id 筛选,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_直接查询系列分配", func(t *testing.T) {
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d",
childShop.ID, series.ID)
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
items := data["items"].([]interface{})
require.Len(t, items, 1, "应返回唯一匹配记录")
allocation := items[0].(map[string]interface{})
assert.Equal(t, float64(5000), allocation["one_time_commission_amount"],
"佣金金额应为 5000 分")
})
// ------------------------------------------------------------
// Scenario: 系列分配不存在
// GIVEN: shop_id + series_id 组合不存在分配记录
// WHEN: 查询该组合
// THEN: 返回空列表
//
// 破坏点:如果查询不正确处理空结果,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_系列分配不存在", func(t *testing.T) {
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=99999",
childShop.ID)
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
data := result.Data.(map[string]interface{})
list := data["items"].([]interface{})
assert.Empty(t, list, "不存在的组合应返回空列表")
})
}
func TestCommissionCalculation_EnableOneTimeCommission_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
series := createCommissionTestSeriesWithConfig(t, env, "启用佣金系列", true)
// ------------------------------------------------------------
// Scenario: 检查系列是否启用一次性佣金
// GIVEN: 系列配置 enable_one_time_commission = true
// WHEN: 查询系列详情
// THEN: 响应包含 enable_one_time_commission = true
//
// 破坏点:如果系列 API 不返回 enable_one_time_commission 字段,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_检查系列是否启用一次性佣金", func(t *testing.T) {
path := fmt.Sprintf("/api/admin/package-series/%d", series.ID)
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
enableOneTime, ok := data["enable_one_time_commission"]
assert.True(t, ok, "响应应包含 enable_one_time_commission 字段")
assert.Equal(t, true, enableOneTime, "应为 true")
})
// ------------------------------------------------------------
// Scenario: 批量查询启用一次性佣金的系列
// GIVEN: 存在多个系列,部分启用一次性佣金
// WHEN: 查询系列列表并按 enable_one_time_commission 筛选
// THEN: 返回符合条件的系列
//
// 破坏点:如果不支持 enable_one_time_commission 筛选,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_批量查询启用一次性佣金的系列", func(t *testing.T) {
createCommissionTestSeriesWithConfig(t, env, "禁用佣金系列", false)
resp, err := env.AsSuperAdmin().
Request("GET", "/api/admin/package-series?enable_one_time_commission=true", nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
data := result.Data.(map[string]interface{})
list := data["items"].([]interface{})
for _, item := range list {
seriesItem := item.(map[string]interface{})
enableVal, hasField := seriesItem["enable_one_time_commission"]
if hasField {
assert.Equal(t, true, enableVal, "筛选结果应全部为启用状态")
}
}
})
}
func TestCommissionCalculation_ChainAllocation_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
level1Shop := env.CreateTestShop("一级代理", 1, nil)
level2Shop := env.CreateTestShop("二级代理", 2, &level1Shop.ID)
level3Shop := env.CreateTestShop("三级代理", 3, &level2Shop.ID)
series := createCommissionTestSeries(t, env, "链式分配系列")
// ------------------------------------------------------------
// Scenario: 链式分配金额计算
// GIVEN:
// - 平台给一级one_time_commission_amount = 10000100元
// - 一级给二级one_time_commission_amount = 800080元
// - 二级给三级one_time_commission_amount = 500050元
// WHEN: 查询各级的系列分配
// THEN: 各级金额正确
//
// 破坏点:如果分配金额不正确保存,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_链式分配金额计算", func(t *testing.T) {
createPlatformSeriesAllocationForCommission(t, env, level1Shop.ID, series.ID, 10000)
createSeriesAllocationForCommission(t, env, level1Shop.ID, level2Shop.ID, series.ID, 8000)
createSeriesAllocationForCommission(t, env, level2Shop.ID, level3Shop.ID, series.ID, 5000)
verifyAllocationAmount(t, env, level1Shop.ID, series.ID, 10000)
verifyAllocationAmount(t, env, level2Shop.ID, series.ID, 8000)
verifyAllocationAmount(t, env, level3Shop.ID, series.ID, 5000)
})
// ------------------------------------------------------------
// Scenario: 单级代理
// GIVEN: 一级代理直接销售(无下级)
// WHEN: 查询一级的系列分配
// THEN: 一级获得完整的 one_time_commission_amount
//
// 破坏点:如果单级分配不生效,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_单级代理", func(t *testing.T) {
singleShop := env.CreateTestShop("单级代理", 1, nil)
singleSeries := createCommissionTestSeries(t, env, "单级系列")
createPlatformSeriesAllocationForCommission(t, env, singleShop.ID, singleSeries.ID, 10000)
verifyAllocationAmount(t, env, singleShop.ID, singleSeries.ID, 10000)
})
}
func TestCommissionCalculation_TriggerConfig_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createCommissionTestSeries(t, env, "触发配置系列")
createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000)
// ------------------------------------------------------------
// Scenario: 累计达到阈值触发佣金配置
// GIVEN: 系列分配设置为累计充值触发,阈值 1000 元
// WHEN: 创建系列分配
// THEN: 配置正确保存
//
// 破坏点:如果触发配置不保存,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_累计达到阈值触发佣金配置", func(t *testing.T) {
body := map[string]interface{}{
"shop_id": childShop.ID,
"series_id": series.ID,
"one_time_commission_amount": 5000,
"enable_one_time_commission": true,
"one_time_commission_trigger": "accumulated_recharge",
"one_time_commission_threshold": 100000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsSuperAdmin().
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
assert.Equal(t, true, data["enable_one_time_commission"])
assert.Equal(t, "accumulated_recharge", data["one_time_commission_trigger"])
assert.Equal(t, float64(100000), data["one_time_commission_threshold"])
})
// ------------------------------------------------------------
// Scenario: 首次充值触发配置
// GIVEN: 系列分配设置为首次充值触发,阈值 100 元
// WHEN: 创建系列分配
// THEN: 配置正确保存
//
// 破坏点:如果 first_recharge 触发类型不支持,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_首次充值触发配置", func(t *testing.T) {
newChildShop := env.CreateTestShop("首充测试店铺", 2, &parentShop.ID)
body := map[string]interface{}{
"shop_id": newChildShop.ID,
"series_id": series.ID,
"one_time_commission_amount": 5000,
"enable_one_time_commission": true,
"one_time_commission_trigger": "first_recharge",
"one_time_commission_threshold": 10000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsSuperAdmin().
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
assert.Equal(t, "first_recharge", data["one_time_commission_trigger"])
})
}
func TestCommissionStats_Allocation_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
series := createCommissionTestSeries(t, env, "统计测试系列")
// ------------------------------------------------------------
// Scenario: 创建佣金统计记录关联系列分配
// GIVEN: 存在系列分配记录
// WHEN: 查询佣金统计
// THEN: 统计记录的 allocation_id 指向 ShopSeriesAllocation.id
//
// 破坏点:如果统计不关联系列分配,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_佣金统计关联系列分配", func(t *testing.T) {
allocation := createPlatformSeriesAllocationForCommission(t, env, parentShop.ID, series.ID, 10000)
path := fmt.Sprintf("/api/admin/shop-series-commission-stats?shop_id=%d&series_id=%d",
parentShop.ID, series.ID)
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode == 200 {
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
if result.Code == 0 && result.Data != nil {
data := result.Data.(map[string]interface{})
if list, ok := data["items"].([]interface{}); ok && len(list) > 0 {
stats := list[0].(map[string]interface{})
if allocationID, exists := stats["allocation_id"]; exists {
assert.Equal(t, float64(allocation.ID), allocationID,
"统计应关联到系列分配 ID")
}
}
}
}
})
}
// ============================================================
// 辅助函数
// ============================================================
func createCommissionTestSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
t.Helper()
timestamp := time.Now().UnixNano()
series := &model.PackageSeries{
SeriesCode: fmt.Sprintf("COMM_SERIES_%d", timestamp),
SeriesName: name,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
}
err := env.TX.Create(series).Error
require.NoError(t, err, "创建测试系列失败")
return series
}
func createCommissionTestSeriesWithConfig(t *testing.T, env *integ.IntegrationTestEnv, name string, enableOneTime bool) *model.PackageSeries {
t.Helper()
timestamp := time.Now().UnixNano()
series := &model.PackageSeries{
SeriesCode: fmt.Sprintf("COMM_SERIES_%d", timestamp),
SeriesName: name,
Status: constants.StatusEnabled,
EnableOneTimeCommission: enableOneTime,
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
}
err := env.TX.Create(series).Error
require.NoError(t, err, "创建测试系列失败")
return series
}
func createPlatformSeriesAllocationForCommission(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
t.Helper()
allocation := &model.ShopSeriesAllocation{
ShopID: shopID,
SeriesID: seriesID,
AllocatorShopID: 0,
OneTimeCommissionAmount: amount,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
}
err := env.TX.Create(allocation).Error
require.NoError(t, err, "创建平台系列分配失败")
return allocation
}
func createSeriesAllocationForCommission(t *testing.T, env *integ.IntegrationTestEnv, allocatorShopID, shopID, seriesID uint, amount int64) *model.ShopSeriesAllocation {
t.Helper()
allocation := &model.ShopSeriesAllocation{
ShopID: shopID,
SeriesID: seriesID,
AllocatorShopID: allocatorShopID,
OneTimeCommissionAmount: amount,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
}
err := env.TX.Create(allocation).Error
require.NoError(t, err, "创建系列分配失败")
return allocation
}
func verifyAllocationAmount(t *testing.T, env *integ.IntegrationTestEnv, shopID, seriesID uint, expectedAmount int64) {
t.Helper()
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d&series_id=%d", shopID, seriesID)
resp, err := env.AsSuperAdmin().Request("GET", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, 0, result.Code)
data := result.Data.(map[string]interface{})
list := data["items"].([]interface{})
require.NotEmpty(t, list, "应存在分配记录")
allocation := list[0].(map[string]interface{})
assert.Equal(t, float64(expectedAmount), allocation["one_time_commission_amount"],
"店铺 %d 系列 %d 的佣金金额应为 %d", shopID, seriesID, expectedAmount)
}