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

322
tests/acceptance/README.md Normal file
View File

@@ -0,0 +1,322 @@
# 验收测试 (Acceptance Tests)
验收测试验证单个 API 的契约:给定输入,期望输出。
## 核心原则
1. **来源于 Spec**:每个测试用例对应 Spec 中的一个 Scenario
2. **测试先于实现**:在功能实现前生成,预期全部 FAIL
3. **契约验证**:验证 API 的输入输出契约,不测试内部实现
4. **必须有破坏点**:每个测试必须注释说明什么代码变更会导致失败
## 目录结构
```
tests/acceptance/
├── README.md # 本文件
├── account_acceptance_test.go # 账号管理验收测试
├── package_acceptance_test.go # 套餐管理验收测试
├── shop_package_acceptance_test.go # 店铺套餐分配验收测试
└── ...
```
## 测试模板
```go
package acceptance
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"junhong_cmp_fiber/tests/testutils"
)
// ============================================================
// 验收测试:{功能名称}
// 来源openspec/changes/{change-name}/specs/{capability}/spec.md
// ============================================================
func Test{Capability}_Acceptance(t *testing.T) {
env := testutils.NewIntegrationTestEnv(t)
// ------------------------------------------------------------
// Scenario: {场景名称}
// GIVEN: {前置条件}
// WHEN: {触发动作}
// THEN: {预期结果}
// AND: {额外验证}
//
// 破坏点:{描述什么代码变更会导致此测试失败}
// ------------------------------------------------------------
t.Run("Scenario_{场景名称}", func(t *testing.T) {
// GIVEN: 设置前置条件
client := env.AsSuperAdmin()
// WHEN: 执行操作
body := map[string]interface{}{
// 请求体
}
resp, err := client.Request("POST", "/api/admin/xxx", body)
require.NoError(t, err)
// THEN: 验证结果
assert.Equal(t, 200, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
assert.Equal(t, 0, int(result["code"].(float64)))
// AND: 额外验证(如数据库状态)
// ...
})
}
```
## 测试分类
### 正常场景 (Happy Path)
```go
t.Run("Scenario_成功创建资源", func(t *testing.T) {
// 测试正常流程
})
```
### 参数校验
```go
t.Run("Scenario_参数缺失返回400", func(t *testing.T) {
// 测试缺少必填参数
})
t.Run("Scenario_参数格式错误返回400", func(t *testing.T) {
// 测试参数格式不符合要求
})
```
### 权限校验
```go
t.Run("Scenario_无权限返回403", func(t *testing.T) {
// 测试权限不足的情况
})
t.Run("Scenario_跨店铺访问返回403", func(t *testing.T) {
// 测试越权访问
})
```
### 业务规则
```go
t.Run("Scenario_重复创建返回409", func(t *testing.T) {
// 测试业务规则冲突
})
t.Run("Scenario_删除已使用资源返回400", func(t *testing.T) {
// 测试业务规则限制
})
```
## 破坏点注释规范
每个测试必须包含"破坏点"注释,说明什么代码变更会导致测试失败:
```go
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
// 破坏点:如果响应不包含创建的资源 ID此测试将失败
// 破坏点:如果删除权限检查中间件,此测试将失败
```
**为什么需要破坏点**
1. 证明测试真正验证了功能
2. 帮助理解测试意图
3. 重构时快速定位影响
## Table-Driven 模式
对于同一 API 的多个场景,使用 table-driven 模式:
```go
func TestPackage_Create_Validation(t *testing.T) {
env := testutils.NewIntegrationTestEnv(t)
tests := []struct {
name string
body map[string]interface{}
expectedStatus int
expectedCode int
breakpoint string
}{
{
name: "名称为空",
body: map[string]interface{}{
"name": "",
"price": 9900,
},
expectedStatus: 400,
expectedCode: 4000, // CodeInvalidParam
breakpoint: "移除 name 必填校验",
},
{
name: "价格为负",
body: map[string]interface{}{
"name": "测试套餐",
"price": -100,
},
expectedStatus: 400,
expectedCode: 4000,
breakpoint: "移除 price >= 0 校验",
},
{
name: "时长为0",
body: map[string]interface{}{
"name": "测试套餐",
"price": 9900,
"duration": 0,
},
expectedStatus: 400,
expectedCode: 4000,
breakpoint: "移除 duration > 0 校验",
},
}
for _, tt := range tests {
t.Run("Scenario_"+tt.name, func(t *testing.T) {
// 破坏点: {tt.breakpoint}
client := env.AsSuperAdmin()
resp, err := client.Request("POST", "/api/admin/packages", tt.body)
require.NoError(t, err)
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
var result map[string]interface{}
err = resp.JSON(&result)
require.NoError(t, err)
assert.Equal(t, tt.expectedCode, int(result["code"].(float64)))
})
}
}
```
## 运行测试
```bash
# 运行所有验收测试
source .env.local && go test -v ./tests/acceptance/...
# 运行特定功能的验收测试
source .env.local && go test -v ./tests/acceptance/... -run TestPackage
# 运行特定场景
source .env.local && go test -v ./tests/acceptance/... -run "Scenario_成功创建"
```
## 测试环境
验收测试使用 `IntegrationTestEnv`,提供:
- **事务隔离**:每个测试在独立事务中运行,自动回滚
- **Redis 清理**:测试前自动清理相关 Redis 键
- **身份切换**:支持不同角色的请求
```go
env := testutils.NewIntegrationTestEnv(t)
// 以超级管理员身份请求
env.AsSuperAdmin().Request("GET", "/api/admin/xxx", nil)
// 以平台用户身份请求
env.AsPlatformUser(accountID).Request("GET", "/api/admin/xxx", nil)
// 以代理商身份请求
env.AsShopAgent(shopID).Request("GET", "/api/admin/xxx", nil)
// 以企业用户身份请求
env.AsEnterprise(enterpriseID).Request("GET", "/api/admin/xxx", nil)
```
## 与 Spec 的对应关系
```markdown
# Spec 中的 Scenario
#### Scenario: 成功创建套餐
- **GIVEN** 用户已登录且有创建权限
- **WHEN** POST /api/admin/packages with valid data
- **THEN** 返回 201 和套餐详情
- **AND** 数据库中存在该套餐记录
```
对应测试:
```go
// 直接从 Spec Scenario 转换
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
// GIVEN: 用户已登录且有创建权限
client := env.AsSuperAdmin()
// WHEN: POST /api/admin/packages with valid data
resp, err := client.Request("POST", "/api/admin/packages", validBody)
// THEN: 返回 201 和套餐详情
assert.Equal(t, 201, resp.StatusCode)
// AND: 数据库中存在该套餐记录
// 验证数据库状态
})
```
## 常见问题
### Q: 验收测试和集成测试的区别?
| 方面 | 验收测试 | 集成测试 |
|------|---------|---------|
| 来源 | Spec Scenario | 开发者编写 |
| 目的 | 验证 API 契约 | 验证系统集成 |
| 粒度 | 单 API | 可能涉及多 API |
| 时机 | 实现前生成 | 实现后编写 |
### Q: 测试 PASS 了但功能还没实现?
说明测试写得太弱。检查:
1. 是否验证了响应状态码
2. 是否验证了响应体结构
3. 是否验证了数据库状态变化
4. 破坏点是否准确
### Q: 如何处理需要前置数据的测试?
在 GIVEN 阶段创建必要的前置数据:
```go
t.Run("Scenario_删除已分配的套餐失败", func(t *testing.T) {
// GIVEN: 存在一个已分配给店铺的套餐
client := env.AsSuperAdmin()
// 创建套餐
createResp, _ := client.Request("POST", "/api/admin/packages", packageBody)
var createResult map[string]interface{}
createResp.JSON(&createResult)
packageID := uint(createResult["data"].(map[string]interface{})["id"].(float64))
// 分配给店铺
client.Request("POST", "/api/admin/shop-packages", map[string]interface{}{
"package_id": packageID,
"shop_id": 1,
})
// WHEN: 尝试删除套餐
resp, _ := client.Request("DELETE", fmt.Sprintf("/api/admin/packages/%d", packageID), nil)
// THEN: 返回 400
assert.Equal(t, 400, resp.StatusCode)
})
```

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)
}

View File

@@ -0,0 +1,847 @@
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"
)
// ============================================================
// 验收测试:套餐系列分配 (ShopSeriesAllocation)
// 来源openspec/changes/refactor-one-time-commission-allocation/specs/shop-series-allocation/spec.md
// ============================================================
func TestShopSeriesAllocation_Create_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
// 准备测试数据:创建店铺层级和套餐系列
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createTestPackageSeries(t, env, "测试系列")
// 先为一级代理创建系列分配(平台分配)
platformAllocation := createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
// ------------------------------------------------------------
// Scenario: 成功分配套餐系列
// GIVEN: 代理已有该系列的分配权限
// WHEN: POST /api/admin/shop-series-allocations 设置 one_time_commission_amount = 5000
// THEN: 返回 200 和分配记录详情
// AND: 下级代理的一次性佣金上限为 50 元
//
// 破坏点:如果 Handler 不调用 Service.Create此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_成功分配套餐系列", func(t *testing.T) {
body := map[string]interface{}{
"shop_id": childShop.ID,
"series_id": series.ID,
"one_time_commission_amount": 5000, // 50 元
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
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, "应返回成功: %s", result.Message)
// 验证响应包含 one_time_commission_amount
data, ok := result.Data.(map[string]interface{})
require.True(t, ok, "响应 data 应为对象")
assert.Equal(t, float64(5000), data["one_time_commission_amount"], "一次性佣金金额应为 5000 分")
})
// ------------------------------------------------------------
// Scenario: 下级金额不能超过上级
// GIVEN: 上级分配金额为 10000 分100 元)
// WHEN: 尝试为下级分配 15000 分150 元)
// THEN: 返回 400 错误 "一次性佣金金额不能超过您的分配上限"
//
// 破坏点:如果移除金额上限校验,此测试将失败
// ------------------------------------------------------------
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": 15000, // 超过上级的 10000
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code, "应返回错误")
assert.Contains(t, result.Message, "超过", "错误消息应包含'超过'")
})
// ------------------------------------------------------------
// Scenario: 分配时启用一次性佣金和强充
// GIVEN: 代理有分配权限
// WHEN: POST 创建分配,启用一次性佣金(累计充值触发,阈值 1000 元启用强充100 元)
// THEN: 系统保存完整配置
//
// 破坏点:如果不保存 enable_one_time_commission 等字段,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_分配时启用一次性佣金和强充", func(t *testing.T) {
newChildShop := env.CreateTestShop("新下级店铺2", 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": "accumulated_recharge",
"one_time_commission_threshold": 100000, // 1000 元
"enable_force_recharge": true,
"force_recharge_amount": 10000, // 100 元
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
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"])
assert.Equal(t, true, data["enable_force_recharge"])
assert.Equal(t, float64(10000), data["force_recharge_amount"])
})
// ------------------------------------------------------------
// Scenario: 尝试分配未拥有的系列
// GIVEN: 代理没有某系列的分配权限
// WHEN: 尝试为下级分配该系列
// THEN: 返回 403/400 "您没有该套餐系列的分配权限"
//
// 破坏点:如果不检查代理是否拥有系列权限,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_尝试分配未拥有的系列", func(t *testing.T) {
unownedSeries := createTestPackageSeries(t, env, "未分配系列")
body := map[string]interface{}{
"shop_id": childShop.ID,
"series_id": unownedSeries.ID,
"one_time_commission_amount": 5000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
// 应返回 400 或 403
assert.True(t, resp.StatusCode == 400 || resp.StatusCode == 403,
"应返回 400 或 403实际: %d", resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code)
})
// ------------------------------------------------------------
// Scenario: 尝试分配给非直属下级
// GIVEN: 店铺 A 是一级,店铺 B 是二级A 的下级),店铺 C 是三级B 的下级)
// WHEN: 店铺 A 尝试直接分配给店铺 C
// THEN: 返回 403 "只能为直属下级分配套餐"
//
// 破坏点:如果不检查是否为直属下级,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_尝试分配给非直属下级", func(t *testing.T) {
grandChildShop := env.CreateTestShop("三级代理", 3, &childShop.ID)
body := map[string]interface{}{
"shop_id": grandChildShop.ID, // 非直属下级
"series_id": series.ID,
"one_time_commission_amount": 5000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.True(t, resp.StatusCode == 400 || resp.StatusCode == 403)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code)
})
// ------------------------------------------------------------
// Scenario: 重复分配同一系列
// GIVEN: 已为下级店铺分配了某系列
// WHEN: 再次尝试分配同一系列
// THEN: 返回 409 "该店铺已分配此套餐系列"
//
// 破坏点:如果不检查唯一索引,此测试将失败
// ------------------------------------------------------------
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,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
// 第二次分配(应失败)
resp2, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("POST", "/api/admin/shop-series-allocations", jsonBody)
require.NoError(t, err)
defer resp2.Body.Close()
assert.True(t, resp2.StatusCode == 400 || resp2.StatusCode == 409,
"重复分配应返回 400 或 409实际: %d", resp2.StatusCode)
})
_ = platformAllocation // 使用变量避免编译警告
}
func TestShopSeriesAllocation_List_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createTestPackageSeries(t, env, "列表测试系列")
// 创建分配记录
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
// ------------------------------------------------------------
// Scenario: 查询所有分配
// GIVEN: 存在多条分配记录
// WHEN: GET /api/admin/shop-series-allocations 不带筛选条件
// THEN: 返回该代理创建的所有分配记录
// AND: 每条记录包含 one_time_commission_amount 字段
//
// 破坏点:如果 List API 不返回数据,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_查询所有分配", func(t *testing.T) {
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("GET", "/api/admin/shop-series-allocations", 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, ok := data["items"].([]interface{})
require.True(t, ok, "响应应包含 items 字段")
require.NotEmpty(t, items, "列表不应为空")
// 验证第一条记录包含 one_time_commission_amount
firstItem := items[0].(map[string]interface{})
_, hasAmount := firstItem["one_time_commission_amount"]
assert.True(t, hasAmount, "记录应包含 one_time_commission_amount 字段")
})
// ------------------------------------------------------------
// Scenario: 按店铺筛选
// GIVEN: 存在多个店铺的分配记录
// WHEN: GET /api/admin/shop-series-allocations?shop_id=xxx
// THEN: 只返回该店铺的分配记录
//
// 破坏点:如果不支持 shop_id 筛选,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_按店铺筛选", func(t *testing.T) {
path := fmt.Sprintf("/api/admin/shop-series-allocations?shop_id=%d", childShop.ID)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
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{})
for _, item := range items {
record := item.(map[string]interface{})
assert.Equal(t, float64(childShop.ID), record["shop_id"],
"筛选结果应只包含指定店铺")
}
})
}
func TestShopSeriesAllocation_Update_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createTestPackageSeries(t, env, "更新测试系列")
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
allocation := createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
// ------------------------------------------------------------
// Scenario: 更新一次性佣金金额
// GIVEN: 存在一条分配记录,金额为 5000
// WHEN: PUT /api/admin/shop-series-allocations/:id 将金额改为 6000
// THEN: 更新成功,返回更新后的记录
//
// 破坏点:如果 Update API 不保存金额变更,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_更新一次性佣金金额", func(t *testing.T) {
body := map[string]interface{}{
"one_time_commission_amount": 6000,
}
jsonBody, _ := json.Marshal(body)
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("PUT", path, 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, float64(6000), data["one_time_commission_amount"])
})
// ------------------------------------------------------------
// Scenario: 更新金额不能超过上级上限
// GIVEN: 上级分配金额上限为 10000
// WHEN: 尝试将金额更新为 15000
// THEN: 返回 400 错误
//
// 破坏点:如果更新时不检查金额上限,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_更新金额不能超过上级上限", func(t *testing.T) {
body := map[string]interface{}{
"one_time_commission_amount": 15000, // 超过上级的 10000
}
jsonBody, _ := json.Marshal(body)
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("PUT", path, jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
// ------------------------------------------------------------
// Scenario: 更新强充配置
// GIVEN: 分配记录存在
// WHEN: PUT 启用强充,设置金额 100 元
// THEN: 配置更新成功
//
// 破坏点:如果不保存强充配置,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_更新强充配置", func(t *testing.T) {
body := map[string]interface{}{
"enable_force_recharge": true,
"force_recharge_amount": 10000,
}
jsonBody, _ := json.Marshal(body)
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("PUT", path, 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)
data := result.Data.(map[string]interface{})
assert.Equal(t, true, data["enable_force_recharge"])
assert.Equal(t, float64(10000), data["force_recharge_amount"])
})
// ------------------------------------------------------------
// Scenario: 更新不存在的分配
// GIVEN: 分配 ID 不存在
// WHEN: PUT /api/admin/shop-series-allocations/99999
// THEN: 返回 404 "分配记录不存在"
//
// 破坏点:如果不检查记录是否存在,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_更新不存在的分配", func(t *testing.T) {
body := map[string]interface{}{
"one_time_commission_amount": 5000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("PUT", "/api/admin/shop-series-allocations/99999", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 404, resp.StatusCode)
})
}
func TestShopSeriesAllocation_Delete_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createTestPackageSeries(t, env, "删除测试系列")
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
// ------------------------------------------------------------
// Scenario: 删除系列分配时检查套餐分配
// GIVEN: 系列分配存在,且有依赖的套餐分配
// WHEN: DELETE /api/admin/shop-series-allocations/:id
// THEN: 返回 400 "存在关联的套餐分配,无法删除"
//
// 破坏点:如果不检查套餐分配依赖,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_删除系列分配时检查套餐分配", func(t *testing.T) {
// 创建系列分配
allocation := createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
// 创建依赖的套餐分配
pkg := createTestPackage(t, env, series.ID, "测试套餐")
createPackageAllocationWithSeriesAllocation(t, env, childShop.ID, pkg.ID, allocation.ID)
// 尝试删除系列分配
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("DELETE", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result.Message, "关联", "错误消息应提及关联")
})
// ------------------------------------------------------------
// Scenario: 成功删除无依赖的系列分配
// GIVEN: 系列分配存在,无套餐分配依赖
// WHEN: DELETE /api/admin/shop-series-allocations/:id
// THEN: 删除成功
//
// 破坏点:如果 Delete API 不工作,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_成功删除无依赖的系列分配", func(t *testing.T) {
newChildShop := env.CreateTestShop("新下级", 2, &parentShop.ID)
allocation := createSeriesAllocationDirectly(t, env, parentShop.ID, newChildShop.ID, series.ID, 5000)
path := fmt.Sprintf("/api/admin/shop-series-allocations/%d", allocation.ID)
resp, err := env.AsUser(createTestAgentAccount(t, env, parentShop.ID)).
Request("DELETE", path, nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
})
}
func TestShopSeriesAllocation_Platform_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
series := createTestPackageSeries(t, env, "平台分配测试系列")
// 设置系列的一次性佣金上限(假设固定 150 元)
setSeriesOneTimeCommissionLimit(t, env, series.ID, 15000)
// ------------------------------------------------------------
// Scenario: 平台为一级代理分配
// GIVEN: 平台管理员
// WHEN: POST 为一级代理分配套餐系列,设置 one_time_commission_amount = 10000
// THEN: 分配成功
//
// 破坏点:如果平台无法创建分配,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_平台为一级代理分配", func(t *testing.T) {
body := map[string]interface{}{
"shop_id": parentShop.ID,
"series_id": series.ID,
"one_time_commission_amount": 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)
})
// ------------------------------------------------------------
// Scenario: 平台可自由设定金额
// GIVEN: 平台管理员
// WHEN: 平台为一级代理分配任意金额(如 20000
// THEN: 分配成功(平台无上限限制)
//
// 破坏点:如果平台分配被上限限制,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_平台可自由设定金额", func(t *testing.T) {
newShop := env.CreateTestShop("新一级代理", 1, nil)
body := map[string]interface{}{
"shop_id": newShop.ID,
"series_id": series.ID,
"one_time_commission_amount": 20000,
}
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)
})
// ------------------------------------------------------------
// Scenario: 平台配置强充要求
// GIVEN: 平台管理员
// WHEN: POST 为一级代理分配系列启用强充force_recharge_amount = 10000
// THEN: 配置保存成功
//
// 破坏点:如果不保存强充配置,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_平台配置强充要求", func(t *testing.T) {
newShop := env.CreateTestShop("强充测试店铺", 1, nil)
body := map[string]interface{}{
"shop_id": newShop.ID,
"series_id": series.ID,
"one_time_commission_amount": 10000,
"enable_force_recharge": true,
"force_recharge_amount": 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)
data := result.Data.(map[string]interface{})
assert.Equal(t, true, data["enable_force_recharge"])
assert.Equal(t, float64(10000), data["force_recharge_amount"])
})
}
func TestShopPackageAllocation_SeriesDependency_Acceptance(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
parentShop := env.CreateTestShop("一级代理", 1, nil)
childShop := env.CreateTestShop("二级代理", 2, &parentShop.ID)
series := createTestPackageSeries(t, env, "依赖测试系列")
pkg := createTestPackage(t, env, series.ID, "依赖测试套餐")
// ------------------------------------------------------------
// Scenario: 未分配系列时分配套餐失败
// GIVEN: 下级店铺未被分配系列 X
// WHEN: 代理尝试为下级分配套餐 A属于系列 X
// THEN: 返回 400 "请先分配该套餐所属的系列"
//
// 破坏点:如果不检查系列分配依赖,此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_未分配系列时分配套餐失败", func(t *testing.T) {
body := map[string]interface{}{
"shop_id": childShop.ID,
"package_id": pkg.ID,
"cost_price": 5000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsSuperAdmin().
Request("POST", "/api/admin/shop-package-allocations", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result.Message, "系列", "错误消息应提及系列")
})
// ------------------------------------------------------------
// Scenario: 先分配系列再分配套餐
// GIVEN: 下级店铺已被分配系列 X
// WHEN: 代理为下级分配套餐 A属于系列 X
// THEN: 分配成功,套餐分配关联到系列分配记录
//
// 破坏点:如果不关联 series_allocation_id此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_先分配系列再分配套餐", func(t *testing.T) {
// 先分配系列
createPlatformSeriesAllocation(t, env, parentShop.ID, series.ID, 10000)
seriesAllocation := createSeriesAllocationDirectly(t, env, parentShop.ID, childShop.ID, series.ID, 5000)
// 再分配套餐
body := map[string]interface{}{
"shop_id": childShop.ID,
"package_id": pkg.ID,
"cost_price": 5000,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsSuperAdmin().
Request("POST", "/api/admin/shop-package-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, float64(seriesAllocation.ID), data["series_allocation_id"],
"套餐分配应关联到系列分配")
})
// ------------------------------------------------------------
// Scenario: 套餐分配只包含成本价
// GIVEN: 套餐分配 API
// WHEN: 创建或查询套餐分配
// THEN: 请求/响应只包含 cost_price不包含 one_time_commission_amount
//
// 破坏点:如果响应包含 one_time_commission_amount此测试将失败
// ------------------------------------------------------------
t.Run("Scenario_套餐分配只包含成本价", func(t *testing.T) {
// 查询已创建的套餐分配
resp, err := env.AsSuperAdmin().
Request("GET", fmt.Sprintf("/api/admin/shop-package-allocations?shop_id=%d", childShop.ID), 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{})
items := data["items"].([]interface{})
if len(items) > 0 {
firstItem := items[0].(map[string]interface{})
_, hasCostPrice := firstItem["cost_price"]
_, hasOneTimeCommission := firstItem["one_time_commission_amount"]
assert.True(t, hasCostPrice, "应包含 cost_price")
assert.False(t, hasOneTimeCommission, "不应包含 one_time_commission_amount")
}
})
}
// ============================================================
// 辅助函数
// ============================================================
func createTestPackageSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
t.Helper()
timestamp := time.Now().UnixNano()
series := &model.PackageSeries{
SeriesCode: fmt.Sprintf("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 createTestPackage(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, name string) *model.Package {
t.Helper()
timestamp := time.Now().UnixNano()
pkg := &model.Package{
PackageCode: fmt.Sprintf("PKG_%d", timestamp),
PackageName: name,
SeriesID: seriesID,
PackageType: "formal",
DurationMonths: 1,
CostPrice: 5000,
SuggestedRetailPrice: 9900,
Status: constants.StatusEnabled,
ShelfStatus: 1,
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
}
err := env.TX.Create(pkg).Error
require.NoError(t, err, "创建测试套餐失败")
return pkg
}
func createTestAgentAccount(t *testing.T, env *integ.IntegrationTestEnv, shopID uint) *model.Account {
t.Helper()
return env.CreateTestAccount("agent", "password123", constants.UserTypeAgent, &shopID, nil)
}
// createPlatformSeriesAllocation 模拟平台为一级代理创建的系列分配
// 注意:由于 ShopSeriesAllocation 模型可能尚未创建,这里直接通过数据库操作模拟
func createPlatformSeriesAllocation(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
}
// createSeriesAllocationDirectly 直接在数据库创建系列分配记录
func createSeriesAllocationDirectly(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
}
// createPackageAllocationWithSeriesAllocation 创建关联系列分配的套餐分配
func createPackageAllocationWithSeriesAllocation(t *testing.T, env *integ.IntegrationTestEnv, shopID, packageID, seriesAllocationID uint) *model.ShopPackageAllocation {
t.Helper()
allocation := &model.ShopPackageAllocation{
ShopID: shopID,
PackageID: packageID,
SeriesAllocationID: &seriesAllocationID,
CostPrice: 5000,
Status: constants.StatusEnabled,
BaseModel: model.BaseModel{
Creator: 1,
Updater: 1,
},
}
err := env.TX.Create(allocation).Error
require.NoError(t, err, "创建套餐分配失败")
return allocation
}
// setSeriesOneTimeCommissionLimit 设置系列的一次性佣金上限(假设在 PackageSeries 或配置中)
func setSeriesOneTimeCommissionLimit(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, limit int64) {
t.Helper()
// 更新系列配置
err := env.TX.Model(&model.PackageSeries{}).Where("id = ?", seriesID).Updates(map[string]interface{}{
"enable_one_time_commission": true,
// 假设有 one_time_commission_config 字段存储配置
}).Error
require.NoError(t, err, "设置系列佣金上限失败")
}