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