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:
541
tests/flows/README.md
Normal file
541
tests/flows/README.md
Normal file
@@ -0,0 +1,541 @@
|
||||
# 业务流程测试 (Flow Tests)
|
||||
|
||||
流程测试验证多个 API 组合的完整业务场景,确保端到端流程正确。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **来源于 Spec Business Flow**:每个测试对应 Spec 中的一个 Business Flow
|
||||
2. **跨 API 验证**:验证多个 API 调用的组合行为
|
||||
3. **状态共享**:流程中的数据(如 ID)在 steps 之间传递
|
||||
4. **角色切换**:不同 step 可能由不同角色执行
|
||||
5. **必须有破坏点和依赖声明**
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/flows/
|
||||
├── README.md # 本文件
|
||||
├── package_lifecycle_flow_test.go # 套餐完整生命周期
|
||||
├── order_purchase_flow_test.go # 订单购买流程
|
||||
├── commission_settlement_flow_test.go # 佣金结算流程
|
||||
├── iot_card_import_activate_flow_test.go # IoT 卡导入激活流程
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 测试模板
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
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
|
||||
// 参与者:{角色1}, {角色2}, ...
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_{FlowName}(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ========================================================
|
||||
// 流程级共享状态
|
||||
// 在 steps 之间传递的数据
|
||||
// ========================================================
|
||||
var (
|
||||
resourceID uint
|
||||
orderID uint
|
||||
// 其他需要共享的状态...
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: {步骤名称}
|
||||
// 角色: {执行角色}
|
||||
// 调用: {HTTP Method} {Path}
|
||||
// 预期: {预期结果}
|
||||
//
|
||||
// 依赖: 无(首个步骤)
|
||||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_{步骤名称}", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin() // 或其他角色
|
||||
|
||||
body := map[string]interface{}{
|
||||
// 请求体
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/xxx", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提取共享状态
|
||||
data := result["data"].(map[string]interface{})
|
||||
resourceID = uint(data["id"].(float64))
|
||||
require.NotZero(t, resourceID, "资源 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: {步骤名称}
|
||||
// 角色: {执行角色}
|
||||
// 调用: {HTTP Method} {Path}
|
||||
// 预期: {预期结果}
|
||||
//
|
||||
// 依赖: Step 1 的 resourceID
|
||||
// 破坏点:{描述什么代码变更会导致此测试失败}
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_{步骤名称}", func(t *testing.T) {
|
||||
if resourceID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 resourceID")
|
||||
}
|
||||
|
||||
client := env.AsShopAgent(1) // 切换到代理商角色
|
||||
|
||||
resp, err := client.Request("GET", fmt.Sprintf("/api/admin/xxx/%d", resourceID), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// 验证和提取数据...
|
||||
})
|
||||
|
||||
// 更多 steps...
|
||||
}
|
||||
```
|
||||
|
||||
## 流程测试 vs 验收测试
|
||||
|
||||
| 方面 | 验收测试 | 流程测试 |
|
||||
|------|---------|---------|
|
||||
| 来源 | Spec Scenario | Spec Business Flow |
|
||||
| 粒度 | 单 API | 多 API 组合 |
|
||||
| 状态 | 独立 | steps 之间共享 |
|
||||
| 角色 | 通常单一 | 可能多角色 |
|
||||
| 目的 | 验证 API 契约 | 验证业务场景 |
|
||||
|
||||
## 状态共享模式
|
||||
|
||||
### 使用包级变量
|
||||
|
||||
```go
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
packageID uint
|
||||
allocationID uint
|
||||
orderID uint
|
||||
)
|
||||
|
||||
t.Run("Step1_创建套餐", func(t *testing.T) {
|
||||
// ... 创建套餐
|
||||
packageID = extractedID
|
||||
})
|
||||
|
||||
t.Run("Step2_分配套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1")
|
||||
}
|
||||
// 使用 packageID
|
||||
allocationID = extractedID
|
||||
})
|
||||
|
||||
t.Run("Step3_创建订单", func(t *testing.T) {
|
||||
if allocationID == 0 {
|
||||
t.Skip("依赖 Step 2")
|
||||
}
|
||||
// 使用 allocationID
|
||||
orderID = extractedID
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 依赖声明规范
|
||||
|
||||
每个 step 必须声明依赖:
|
||||
|
||||
```go
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 代理商查看可售套餐
|
||||
// 角色: 代理商
|
||||
// 调用: GET /api/admin/shop-packages
|
||||
// 预期: 列表包含刚分配的套餐
|
||||
//
|
||||
// 依赖: Step 1 的 packageID, Step 2 的分配操作
|
||||
// 破坏点:如果查询不按 shop_id 过滤,代理商会看到其他店铺的套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
## 多角色流程
|
||||
|
||||
```go
|
||||
func TestFlow_CrossRoleWorkflow(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var (
|
||||
resourceID uint
|
||||
shopID uint = 1
|
||||
)
|
||||
|
||||
// Step 1: 平台管理员创建资源
|
||||
t.Run("Step1_平台创建资源", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
// ...
|
||||
resourceID = extractedID
|
||||
})
|
||||
|
||||
// Step 2: 平台管理员分配给代理商
|
||||
t.Run("Step2_分配给代理商", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
// ...
|
||||
})
|
||||
|
||||
// Step 3: 代理商查看资源(角色切换!)
|
||||
t.Run("Step3_代理商查看", func(t *testing.T) {
|
||||
client := env.AsShopAgent(shopID) // 切换到代理商
|
||||
// ...
|
||||
})
|
||||
|
||||
// Step 4: 代理商创建订单
|
||||
t.Run("Step4_代理商创建订单", func(t *testing.T) {
|
||||
client := env.AsShopAgent(shopID)
|
||||
// ...
|
||||
})
|
||||
|
||||
// Step 5: 平台管理员查看统计(再次切换)
|
||||
t.Run("Step5_平台查看统计", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 破坏点注释规范
|
||||
|
||||
流程测试的破坏点更侧重于跨 API 的影响:
|
||||
|
||||
```go
|
||||
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||||
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||||
// 破坏点:如果代理商查询不过滤 shop_id,会看到其他店铺的数据
|
||||
// 破坏点:如果订单创建不验证套餐有效期,可能购买过期套餐
|
||||
// 破坏点:如果佣金计算不在事务中,可能导致数据不一致
|
||||
```
|
||||
|
||||
## 异常流程测试
|
||||
|
||||
流程测试也应覆盖异常场景:
|
||||
|
||||
```go
|
||||
func TestFlow_PackageLifecycle_Exceptions(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:尝试删除已分配的套餐
|
||||
// 预期:删除失败,返回业务错误
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_删除已分配套餐", func(t *testing.T) {
|
||||
// Step 1: 创建套餐
|
||||
// Step 2: 分配给店铺
|
||||
// Step 3: 尝试删除(预期失败)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:代理商访问其他店铺的套餐
|
||||
// 预期:访问被拒绝
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_跨店铺访问", func(t *testing.T) {
|
||||
// Step 1: 平台创建并分配给店铺 A
|
||||
// Step 2: 店铺 B 的代理商尝试访问(预期 403)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有流程测试
|
||||
source .env.local && go test -v ./tests/flows/...
|
||||
|
||||
# 运行特定流程
|
||||
source .env.local && go test -v ./tests/flows/... -run TestFlow_PackageLifecycle
|
||||
|
||||
# 运行特定步骤
|
||||
source .env.local && go test -v ./tests/flows/... -run "Step3"
|
||||
```
|
||||
|
||||
## 与 Spec Business Flow 的对应关系
|
||||
|
||||
```markdown
|
||||
# Spec 中的 Business Flow
|
||||
|
||||
### Flow: 套餐完整生命周期
|
||||
|
||||
**参与者**: 平台管理员, 代理商
|
||||
|
||||
**流程步骤**:
|
||||
|
||||
1. **创建套餐**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/packages
|
||||
- 预期: 返回套餐 ID
|
||||
|
||||
2. **分配给代理商**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/shop-packages
|
||||
- 输入: 套餐 ID + 店铺 ID
|
||||
- 预期: 分配成功
|
||||
|
||||
3. **代理商查看可售套餐**
|
||||
- 角色: 代理商
|
||||
- 调用: GET /api/admin/shop-packages
|
||||
- 预期: 列表包含刚分配的套餐
|
||||
```
|
||||
|
||||
直接转换为测试代码:
|
||||
|
||||
```go
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var packageID uint
|
||||
|
||||
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||||
// POST /api/admin/packages
|
||||
})
|
||||
|
||||
t.Run("Step2_分配给代理商", func(t *testing.T) {
|
||||
// POST /api/admin/shop-packages
|
||||
})
|
||||
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
// GET /api/admin/shop-packages
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:IoT 卡导入到激活完整流程
|
||||
// 来源:openspec/changes/iot-card-management/specs/iot-card/spec.md
|
||||
// 参与者:平台管理员, 系统
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_IotCardImportActivate(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
taskID string
|
||||
cardICCIDs []string
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 上传 CSV 文件
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/iot-cards/import
|
||||
// 预期: 返回导入任务 ID
|
||||
//
|
||||
// 依赖: 无
|
||||
// 破坏点:如果文件上传不创建异步任务,后续无法追踪进度
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_上传CSV文件", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// 创建测试 CSV 内容
|
||||
csvContent := "iccid,msisdn,operator\n" +
|
||||
"89860000000000000001,13800000001,中国移动\n" +
|
||||
"89860000000000000002,13800000002,中国移动\n"
|
||||
|
||||
resp, err := client.UploadFile("POST", "/api/admin/iot-cards/import",
|
||||
"file", "cards.csv", []byte(csvContent))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
taskID = data["task_id"].(string)
|
||||
require.NotEmpty(t, taskID, "任务 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 查询导入任务状态
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/iot-cards/import/{taskID}
|
||||
// 预期: 任务状态为 completed,导入成功数量 = 2
|
||||
//
|
||||
// 依赖: Step 1 的 taskID
|
||||
// 破坏点:如果异步任务不更新状态,查询会一直返回 pending
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_查询导入状态", func(t *testing.T) {
|
||||
if taskID == "" {
|
||||
t.Skip("依赖 Step 1 创建的 taskID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// 轮询等待任务完成(最多等待 30 秒)
|
||||
var status string
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := client.Request("GET",
|
||||
fmt.Sprintf("/api/admin/iot-cards/import/%s", taskID), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
resp.JSON(&result)
|
||||
data := result["data"].(map[string]interface{})
|
||||
status = data["status"].(string)
|
||||
|
||||
if status == "completed" || status == "failed" {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
require.Equal(t, "completed", status, "导入任务应该成功完成")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 验证卡片已入库
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/iot-cards
|
||||
// 预期: 能查询到导入的卡片
|
||||
//
|
||||
// 依赖: Step 2 确认任务完成
|
||||
// 破坏点:如果导入任务不写入数据库,查询不到卡片
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_验证卡片入库", func(t *testing.T) {
|
||||
if taskID == "" {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("GET", "/api/admin/iot-cards", map[string]interface{}{
|
||||
"iccid": "89860000000000000001",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
resp.JSON(&result)
|
||||
data := result["data"].(map[string]interface{})
|
||||
list := data["list"].([]interface{})
|
||||
|
||||
require.Len(t, list, 1, "应该能查询到导入的卡片")
|
||||
|
||||
card := list[0].(map[string]interface{})
|
||||
cardICCIDs = append(cardICCIDs, card["iccid"].(string))
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 4: 激活卡片
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/iot-cards/{iccid}/activate
|
||||
// 预期: 卡片状态变为 active
|
||||
//
|
||||
// 依赖: Step 3 获取的 cardICCIDs
|
||||
// 破坏点:如果激活 API 不调用运营商接口,状态不会真正变化
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step4_激活卡片", func(t *testing.T) {
|
||||
if len(cardICCIDs) == 0 {
|
||||
t.Skip("依赖 Step 3 获取的卡片 ICCID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("POST",
|
||||
fmt.Sprintf("/api/admin/iot-cards/%s/activate", cardICCIDs[0]), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 5: 验证卡片状态
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/iot-cards/{iccid}
|
||||
// 预期: 卡片状态为 active
|
||||
//
|
||||
// 依赖: Step 4 激活操作
|
||||
// 破坏点:如果激活后不更新数据库状态,查询还是旧状态
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step5_验证激活状态", func(t *testing.T) {
|
||||
if len(cardICCIDs) == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
resp, err := client.Request("GET",
|
||||
fmt.Sprintf("/api/admin/iot-cards/%s", cardICCIDs[0]), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
resp.JSON(&result)
|
||||
data := result["data"].(map[string]interface{})
|
||||
|
||||
assert.Equal(t, "active", data["status"], "卡片状态应该是 active")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: Step 之间必须按顺序执行吗?
|
||||
|
||||
是的。Go 的 t.Run 保证同一个父测试内的子测试按顺序执行。如果前置 step 失败,后续 step 会因为依赖检查而 skip。
|
||||
|
||||
### Q: 如何处理异步操作?
|
||||
|
||||
使用轮询等待:
|
||||
|
||||
```go
|
||||
// 等待异步任务完成
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
status := checkStatus()
|
||||
if status == "completed" {
|
||||
break
|
||||
}
|
||||
time.Sleep(interval)
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 流程测试太慢怎么办?
|
||||
|
||||
1. 使用 `t.Parallel()` 让不同流程并行(注意数据隔离)
|
||||
2. 减少 sleep 时间,增加轮询频率
|
||||
3. 考虑将部分验证移到验收测试
|
||||
496
tests/flows/one_time_commission_chain_flow_test.go
Normal file
496
tests/flows/one_time_commission_chain_flow_test.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package flows
|
||||
|
||||
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/shop-series-allocation/spec.md
|
||||
// 参与者:平台管理员, 一级代理, 二级代理, 三级代理
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_OneTimeCommissionChainAllocation(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
// ========================================================
|
||||
// 流程级共享状态
|
||||
// ========================================================
|
||||
var (
|
||||
seriesID uint
|
||||
level1ShopID uint
|
||||
level2ShopID uint
|
||||
level3ShopID uint
|
||||
level1AllocationID uint
|
||||
level2AllocationID uint
|
||||
level3AllocationID uint
|
||||
packageID uint
|
||||
level3PackageAllocID uint
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 平台创建套餐系列并启用一次性佣金
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/package-series
|
||||
// 预期: 返回系列 ID,enable_one_time_commission = true
|
||||
//
|
||||
// 依赖: 无
|
||||
// 破坏点:如果系列创建不支持 enable_one_time_commission,后续分配无法启用
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_平台创建套餐系列", func(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"series_code": fmt.Sprintf("CHAIN_SERIES_%d", time.Now().UnixNano()),
|
||||
"series_name": "链式分配测试系列",
|
||||
"description": "测试一次性佣金链式分配",
|
||||
"status": 1,
|
||||
"enable_one_time_commission": true,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/package-series", jsonBody)
|
||||
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, "创建系列失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
seriesID = uint(data["id"].(float64))
|
||||
require.NotZero(t, seriesID, "系列 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 创建三级店铺层级
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shops (3次)
|
||||
// 预期: 创建一级、二级、三级店铺
|
||||
//
|
||||
// 依赖: 无
|
||||
// 破坏点:如果店铺层级关系不正确,后续分配权限检查会失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_创建三级店铺层级", func(t *testing.T) {
|
||||
level1Shop := env.CreateTestShop("一级代理_链式", 1, nil)
|
||||
level1ShopID = level1Shop.ID
|
||||
require.NotZero(t, level1ShopID)
|
||||
|
||||
level2Shop := env.CreateTestShop("二级代理_链式", 2, &level1ShopID)
|
||||
level2ShopID = level2Shop.ID
|
||||
require.NotZero(t, level2ShopID)
|
||||
|
||||
level3Shop := env.CreateTestShop("三级代理_链式", 3, &level2ShopID)
|
||||
level3ShopID = level3Shop.ID
|
||||
require.NotZero(t, level3ShopID)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 平台为一级代理分配系列(金额上限 100 元)
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-series-allocations
|
||||
// 预期: 分配成功,one_time_commission_amount = 10000
|
||||
//
|
||||
// 依赖: Step 1 的 seriesID, Step 2 的 level1ShopID
|
||||
// 破坏点:如果平台无法分配系列,链式分配无法开始
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_平台为一级代理分配系列", func(t *testing.T) {
|
||||
if seriesID == 0 || level1ShopID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level1ShopID,
|
||||
"series_id": seriesID,
|
||||
"one_time_commission_amount": 10000,
|
||||
"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()
|
||||
|
||||
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, "平台分配失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level1AllocationID = uint(data["id"].(float64))
|
||||
assert.Equal(t, float64(10000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 4: 一级代理为二级代理分配系列(金额上限 80 元)
|
||||
// 角色: 一级代理
|
||||
// 调用: POST /api/admin/shop-series-allocations
|
||||
// 预期: 分配成功,one_time_commission_amount = 8000
|
||||
//
|
||||
// 依赖: Step 3 的 level1AllocationID
|
||||
// 破坏点:如果一级无法为下级分配,链式传递中断
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step4_一级为二级分配系列", func(t *testing.T) {
|
||||
if level1AllocationID == 0 {
|
||||
t.Skip("依赖 Step 3")
|
||||
}
|
||||
|
||||
level1Account := env.CreateTestAccount("level1_agent", "password123", constants.UserTypeAgent, &level1ShopID, nil)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level2ShopID,
|
||||
"series_id": seriesID,
|
||||
"one_time_commission_amount": 8000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(level1Account).Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
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, "一级分配给二级失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level2AllocationID = uint(data["id"].(float64))
|
||||
assert.Equal(t, float64(8000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 5: 二级代理为三级代理分配系列(金额上限 50 元)
|
||||
// 角色: 二级代理
|
||||
// 调用: POST /api/admin/shop-series-allocations
|
||||
// 预期: 分配成功,one_time_commission_amount = 5000
|
||||
//
|
||||
// 依赖: Step 4 的 level2AllocationID
|
||||
// 破坏点:如果二级无法为下级分配,链式传递中断
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step5_二级为三级分配系列", func(t *testing.T) {
|
||||
if level2AllocationID == 0 {
|
||||
t.Skip("依赖 Step 4")
|
||||
}
|
||||
|
||||
level2Account := env.CreateTestAccount("level2_agent", "password123", constants.UserTypeAgent, &level2ShopID, nil)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level3ShopID,
|
||||
"series_id": seriesID,
|
||||
"one_time_commission_amount": 5000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsUser(level2Account).Request("POST", "/api/admin/shop-series-allocations", jsonBody)
|
||||
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, "二级分配给三级失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level3AllocationID = uint(data["id"].(float64))
|
||||
assert.Equal(t, float64(5000), data["one_time_commission_amount"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 6: 验证链式分配金额正确
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET /api/admin/shop-series-allocations?shop_id=xxx&series_id=xxx (3次)
|
||||
// 预期: 一级 10000,二级 8000,三级 5000
|
||||
//
|
||||
// 依赖: Step 3-5 的分配记录
|
||||
// 破坏点:如果金额查询不正确,佣金计算会出错
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step6_验证链式分配金额", func(t *testing.T) {
|
||||
if level3AllocationID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
verifyChainAllocationAmount(t, env, level1ShopID, seriesID, 10000)
|
||||
verifyChainAllocationAmount(t, env, level2ShopID, seriesID, 8000)
|
||||
verifyChainAllocationAmount(t, env, level3ShopID, seriesID, 5000)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 7: 平台创建套餐并关联系列
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/packages
|
||||
// 预期: 返回套餐 ID,series_id 正确关联
|
||||
//
|
||||
// 依赖: Step 1 的 seriesID
|
||||
// 破坏点:如果套餐不关联系列,佣金计算无法找到配置
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step7_创建套餐", func(t *testing.T) {
|
||||
if seriesID == 0 {
|
||||
t.Skip("依赖 Step 1")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"package_code": fmt.Sprintf("CHAIN_PKG_%d", time.Now().UnixNano()),
|
||||
"package_name": "链式分配测试套餐",
|
||||
"series_id": seriesID,
|
||||
"package_type": "formal",
|
||||
"duration_months": 1,
|
||||
"cost_price": 5000,
|
||||
"suggested_retail_price": 9900,
|
||||
"status": 1,
|
||||
"shelf_status": 1,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/packages", jsonBody)
|
||||
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, "创建套餐失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
packageID = uint(data["id"].(float64))
|
||||
require.NotZero(t, packageID)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 8: 为三级代理分配套餐(需先有系列分配)
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-package-allocations
|
||||
// 预期: 分配成功,series_allocation_id 关联到系列分配
|
||||
//
|
||||
// 依赖: Step 5 的 level3AllocationID, Step 7 的 packageID
|
||||
// 破坏点:如果套餐分配不检查系列依赖,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step8_为三级代理分配套餐", func(t *testing.T) {
|
||||
if level3AllocationID == 0 || packageID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": level3ShopID,
|
||||
"package_id": packageID,
|
||||
"cost_price": 6000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/shop-package-allocations", jsonBody)
|
||||
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, "套餐分配失败: %s", result.Message)
|
||||
|
||||
data := result.Data.(map[string]interface{})
|
||||
level3PackageAllocID = uint(data["id"].(float64))
|
||||
|
||||
if allocID, ok := data["series_allocation_id"]; ok && allocID != nil {
|
||||
assert.Equal(t, float64(level3AllocationID), allocID,
|
||||
"套餐分配应关联到系列分配")
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 9: 验证完整分配链路
|
||||
// 角色: 平台管理员
|
||||
// 调用: GET APIs
|
||||
// 预期: 所有分配记录正确关联
|
||||
//
|
||||
// 依赖: 所有前置步骤
|
||||
// 破坏点:如果任何环节数据不一致,此验证将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step9_验证完整分配链路", func(t *testing.T) {
|
||||
if level3PackageAllocID == 0 {
|
||||
t.Skip("依赖前置步骤")
|
||||
}
|
||||
|
||||
assert.NotZero(t, seriesID, "系列已创建")
|
||||
assert.NotZero(t, level1AllocationID, "一级系列分配已创建")
|
||||
assert.NotZero(t, level2AllocationID, "二级系列分配已创建")
|
||||
assert.NotZero(t, level3AllocationID, "三级系列分配已创建")
|
||||
assert.NotZero(t, packageID, "套餐已创建")
|
||||
assert.NotZero(t, level3PackageAllocID, "三级套餐分配已创建")
|
||||
})
|
||||
|
||||
_ = level1AllocationID
|
||||
_ = level3PackageAllocID
|
||||
}
|
||||
|
||||
func TestFlow_OneTimeCommissionChainAllocation_Exceptions(t *testing.T) {
|
||||
env := integ.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:下级金额超过上级上限
|
||||
// 预期:分配失败,返回错误
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_下级金额超过上级", func(t *testing.T) {
|
||||
parentShop := env.CreateTestShop("超限测试_父级", 1, nil)
|
||||
childShop := env.CreateTestShop("超限测试_子级", 2, &parentShop.ID)
|
||||
series := createFlowTestSeries(t, env, "超限测试系列")
|
||||
|
||||
createFlowPlatformAllocation(t, env, parentShop.ID, series.ID, 10000)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": childShop.ID,
|
||||
"series_id": series.ID,
|
||||
"one_time_commission_amount": 15000,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
parentAccount := env.CreateTestAccount("parent_agent", "password123", constants.UserTypeAgent, &parentShop.ID, nil)
|
||||
|
||||
resp, err := env.AsUser(parentAccount).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,
|
||||
"超限分配应返回 400 或 403,实际: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 异常流程:未分配系列就分配套餐
|
||||
// 预期:套餐分配失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Exception_未分配系列就分配套餐", func(t *testing.T) {
|
||||
shop := env.CreateTestShop("无系列分配店铺", 1, nil)
|
||||
series := createFlowTestSeries(t, env, "未分配系列")
|
||||
pkg := createFlowTestPackage(t, env, series.ID, "未分配测试套餐")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"shop_id": shop.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, "未分配系列时分配套餐应失败")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
func verifyChainAllocationAmount(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{})
|
||||
items := data["items"].([]interface{})
|
||||
require.NotEmpty(t, items, "店铺 %d 应存在系列 %d 的分配记录", shopID, seriesID)
|
||||
|
||||
allocation := items[0].(map[string]interface{})
|
||||
assert.Equal(t, float64(expectedAmount), allocation["one_time_commission_amount"],
|
||||
"店铺 %d 的佣金金额应为 %d", shopID, expectedAmount)
|
||||
}
|
||||
|
||||
func createFlowTestSeries(t *testing.T, env *integ.IntegrationTestEnv, name string) *model.PackageSeries {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("FLOW_SERIES_%d", timestamp),
|
||||
SeriesName: name,
|
||||
Status: constants.StatusEnabled,
|
||||
EnableOneTimeCommission: true,
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := env.TX.Create(series).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
func createFlowTestPackage(t *testing.T, env *integ.IntegrationTestEnv, seriesID uint, name string) *model.Package {
|
||||
t.Helper()
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
pkg := &model.Package{
|
||||
PackageCode: fmt.Sprintf("FLOW_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 createFlowPlatformAllocation(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
|
||||
}
|
||||
Reference in New Issue
Block a user