feat: 在 IoT 卡和设备列表响应中添加套餐系列名称字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m2s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m2s
主要变更: - 在 StandaloneIotCardResponse 和 DeviceResponse 中添加 series_name 字段 - 在 iot_card 和 device service 中添加 loadSeriesNames 方法批量加载系列名称 - 更新相关方法以支持 series_name 的填充 其他变更: - 新增 OpenSpec 测试生成和共识锁定 skill - 新增 MCP 配置文件 - 更新 CLAUDE.md 项目规范文档 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
133
.claude/commands/opsx/gen-tests.md
Normal file
133
.claude/commands/opsx/gen-tests.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试
|
||||||
|
---
|
||||||
|
|
||||||
|
从 Spec 文档自动生成两类测试:
|
||||||
|
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||||
|
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||||
|
|
||||||
|
**Input**: 可选指定 change 名称(如 `/opsx:gen-tests add-auth`)。如果省略,从上下文推断或提示选择。
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. **选择 change**
|
||||||
|
|
||||||
|
如果提供了名称,使用它。否则:
|
||||||
|
- 从对话上下文推断
|
||||||
|
- 如果只有一个活跃 change,自动选择
|
||||||
|
- 如果模糊,运行 `openspec list --json` 让用户选择
|
||||||
|
|
||||||
|
2. **检查 change 状态**
|
||||||
|
```bash
|
||||||
|
openspec status --change "<name>" --json
|
||||||
|
```
|
||||||
|
确认 specs artifact 已完成(`status: "done"`)
|
||||||
|
|
||||||
|
3. **读取 spec 文件**
|
||||||
|
|
||||||
|
读取 `openspec/changes/<change-name>/specs/*/spec.md` 下的所有 spec 文件。
|
||||||
|
|
||||||
|
4. **解析 Scenarios**
|
||||||
|
|
||||||
|
从每个 spec 文件中提取 `#### Scenario:` 块:
|
||||||
|
```markdown
|
||||||
|
#### Scenario: 成功创建套餐
|
||||||
|
- **GIVEN** 用户已登录且有创建权限
|
||||||
|
- **WHEN** POST /api/admin/packages with valid data
|
||||||
|
- **THEN** 返回 200 和套餐详情
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **解析 Business Flows**(如果存在)
|
||||||
|
|
||||||
|
从 spec 文件中提取 `### Flow:` 块,包含多步骤业务场景。
|
||||||
|
|
||||||
|
6. **生成验收测试**
|
||||||
|
|
||||||
|
输出路径:`tests/acceptance/<capability>_acceptance_test.go`
|
||||||
|
|
||||||
|
模板结构:
|
||||||
|
```go
|
||||||
|
func Test{Capability}_Acceptance(t *testing.T) {
|
||||||
|
env := testutils.NewIntegrationTestEnv(t)
|
||||||
|
|
||||||
|
t.Run("Scenario_{name}", func(t *testing.T) {
|
||||||
|
// GIVEN: ...
|
||||||
|
// WHEN: ...
|
||||||
|
// THEN: ...
|
||||||
|
// 破坏点:...
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **生成流程测试**
|
||||||
|
|
||||||
|
输出路径:`tests/flows/<capability>_<flow>_flow_test.go`
|
||||||
|
|
||||||
|
模板结构:
|
||||||
|
```go
|
||||||
|
func TestFlow_{FlowName}(t *testing.T) {
|
||||||
|
env := testutils.NewIntegrationTestEnv(t)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// 流程级共享状态
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run("Step1_{name}", func(t *testing.T) {
|
||||||
|
// 依赖:...
|
||||||
|
// 破坏点:...
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **运行测试验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期**:全部 FAIL(功能未实现,证明测试有效)
|
||||||
|
|
||||||
|
**如果测试 PASS**:说明测试写得太弱,需要加强
|
||||||
|
|
||||||
|
**Output**
|
||||||
|
|
||||||
|
```
|
||||||
|
## 测试生成完成
|
||||||
|
|
||||||
|
**Change:** <change-name>
|
||||||
|
**来源:** specs/<capability>/spec.md
|
||||||
|
|
||||||
|
### 生成的测试文件
|
||||||
|
|
||||||
|
**验收测试** (tests/acceptance/):
|
||||||
|
- <capability>_acceptance_test.go
|
||||||
|
- Scenario_xxx
|
||||||
|
- Scenario_yyy
|
||||||
|
|
||||||
|
**流程测试** (tests/flows/):
|
||||||
|
- <capability>_<flow>_flow_test.go
|
||||||
|
- Step1_xxx
|
||||||
|
- Step2_yyy
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
$ source .env.local && go test -v ./tests/acceptance/... ./tests/flows/...
|
||||||
|
|
||||||
|
--- FAIL: TestXxx_Acceptance (0.00s)
|
||||||
|
--- FAIL: TestXxx_Acceptance/Scenario_xxx (0.00s)
|
||||||
|
xxx_acceptance_test.go:45: 404 != 200
|
||||||
|
|
||||||
|
✓ 所有测试预期 FAIL(功能未实现)
|
||||||
|
✓ 测试生成完成
|
||||||
|
|
||||||
|
下一步: 开始实现 tasks,每完成一个功能单元运行相关测试验证
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guardrails**
|
||||||
|
|
||||||
|
- 每个 Scenario 必须生成一个测试用例(不要跳过)
|
||||||
|
- 每个测试必须包含"破坏点"注释
|
||||||
|
- 流程测试的 step 必须声明依赖
|
||||||
|
- 使用 IntegrationTestEnv,不要 mock 依赖
|
||||||
|
- 测试必须在功能缺失时 FAIL(不要写永远 PASS 的测试)
|
||||||
|
- 详细模板参考:`.opencode/skills/openspec-generate-acceptance-tests/SKILL.md`
|
||||||
442
.claude/skills/openspec-generate-acceptance-tests/SKILL.md
Normal file
442
.claude/skills/openspec-generate-acceptance-tests/SKILL.md
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
---
|
||||||
|
name: openspec-generate-acceptance-tests
|
||||||
|
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL,证明测试有效。
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: junhong
|
||||||
|
version: "1.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 测试生成 Skill
|
||||||
|
|
||||||
|
从 Spec 文档自动生成两类测试:
|
||||||
|
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||||
|
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||||
|
|
||||||
|
## 触发方式
|
||||||
|
|
||||||
|
```
|
||||||
|
/opsx:gen-tests [change-name]
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不指定 change-name,自动检测当前活跃的 change。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
1. Change 必须存在且包含 spec 文件
|
||||||
|
2. Spec 必须包含 `## Scenarios` 部分
|
||||||
|
3. Spec 建议包含 `## Business Flows` 部分(如果有跨 API 场景)
|
||||||
|
|
||||||
|
检查命令:
|
||||||
|
```bash
|
||||||
|
openspec list --json
|
||||||
|
# 确认 change 存在且有 specs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
### Step 1: 读取 Spec 文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 读取 change 的所有 spec 文件
|
||||||
|
cat openspec/changes/<change-name>/specs/<capability>/spec.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 解析 Scenarios
|
||||||
|
|
||||||
|
从 Spec 中提取所有 Scenario:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
#### Scenario: 成功创建套餐
|
||||||
|
- **GIVEN** 用户已登录且有创建权限
|
||||||
|
- **WHEN** POST /api/admin/packages with valid data
|
||||||
|
- **THEN** 返回 201 和套餐详情
|
||||||
|
- **AND** 数据库中存在该套餐记录
|
||||||
|
```
|
||||||
|
|
||||||
|
解析为结构:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "成功创建套餐",
|
||||||
|
"given": ["用户已登录且有创建权限"],
|
||||||
|
"when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
|
||||||
|
"then": ["返回 201 和套餐详情"],
|
||||||
|
"and": ["数据库中存在该套餐记录"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 解析 Business Flows
|
||||||
|
|
||||||
|
从 Spec 中提取 Business Flow:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Flow: 套餐完整生命周期
|
||||||
|
|
||||||
|
**参与者**: 平台管理员, 代理商
|
||||||
|
|
||||||
|
**流程步骤**:
|
||||||
|
|
||||||
|
1. **创建套餐**
|
||||||
|
- 角色: 平台管理员
|
||||||
|
- 调用: POST /api/admin/packages
|
||||||
|
- 预期: 返回套餐 ID
|
||||||
|
|
||||||
|
2. **分配给代理商**
|
||||||
|
- 角色: 平台管理员
|
||||||
|
- 调用: POST /api/admin/shop-packages
|
||||||
|
- 输入: 套餐 ID + 店铺 ID
|
||||||
|
- 预期: 分配成功
|
||||||
|
|
||||||
|
3. **代理商查看可售套餐**
|
||||||
|
- 角色: 代理商
|
||||||
|
- 调用: GET /api/admin/shop-packages
|
||||||
|
- 预期: 列表包含刚分配的套餐
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 生成验收测试
|
||||||
|
|
||||||
|
**输出路径**: `tests/acceptance/<capability>_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/package-management/specs/package/spec.md
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestPackage_Acceptance(t *testing.T) {
|
||||||
|
env := testutils.NewIntegrationTestEnv(t)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Scenario: 成功创建套餐
|
||||||
|
// GIVEN: 用户已登录且有创建权限
|
||||||
|
// WHEN: POST /api/admin/packages with valid data
|
||||||
|
// THEN: 返回 201 和套餐详情
|
||||||
|
// AND: 数据库中存在该套餐记录
|
||||||
|
//
|
||||||
|
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
|
||||||
|
// GIVEN: 用户已登录且有创建权限
|
||||||
|
client := env.AsSuperAdmin()
|
||||||
|
|
||||||
|
// WHEN: POST /api/admin/packages with valid data
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": "测试套餐",
|
||||||
|
"description": "测试描述",
|
||||||
|
"price": 9900,
|
||||||
|
"duration": 30,
|
||||||
|
}
|
||||||
|
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: 返回 201 和套餐详情
|
||||||
|
assert.Equal(t, 201, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = resp.JSON(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, int(result["code"].(float64)))
|
||||||
|
|
||||||
|
data := result["data"].(map[string]interface{})
|
||||||
|
packageID := uint(data["id"].(float64))
|
||||||
|
assert.NotZero(t, packageID)
|
||||||
|
|
||||||
|
// AND: 数据库中存在该套餐记录
|
||||||
|
// TODO: 实现后取消注释
|
||||||
|
// pkg, err := env.DB().Package.FindByID(ctx, packageID)
|
||||||
|
// require.NoError(t, err)
|
||||||
|
// assert.Equal(t, "测试套餐", pkg.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Scenario: 创建套餐参数校验失败
|
||||||
|
// GIVEN: 用户已登录
|
||||||
|
// WHEN: POST /api/admin/packages with invalid data (name empty)
|
||||||
|
// THEN: 返回 400 和错误信息
|
||||||
|
//
|
||||||
|
// 破坏点:如果删除 handler 中的参数校验,此测试将失败
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
t.Run("Scenario_创建套餐参数校验失败", func(t *testing.T) {
|
||||||
|
// GIVEN: 用户已登录
|
||||||
|
client := env.AsSuperAdmin()
|
||||||
|
|
||||||
|
// WHEN: POST /api/admin/packages with invalid data
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": "", // 空名称
|
||||||
|
"price": -1, // 负价格
|
||||||
|
}
|
||||||
|
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: 返回 400 和错误信息
|
||||||
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: 生成流程测试
|
||||||
|
|
||||||
|
**输出路径**: `tests/flows/<capability>_<flow-name>_flow_test.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package flows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"junhong_cmp_fiber/tests/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 流程测试:套餐完整生命周期
|
||||||
|
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||||
|
// 参与者:平台管理员, 代理商
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||||
|
env := testutils.NewIntegrationTestEnv(t)
|
||||||
|
|
||||||
|
// 流程级共享状态
|
||||||
|
var (
|
||||||
|
packageID uint
|
||||||
|
shopID uint = 1 // 测试店铺 ID
|
||||||
|
)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Step 1: 创建套餐
|
||||||
|
// 角色: 平台管理员
|
||||||
|
// 调用: POST /api/admin/packages
|
||||||
|
// 预期: 返回套餐 ID
|
||||||
|
//
|
||||||
|
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||||||
|
client := env.AsSuperAdmin()
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": "流程测试套餐",
|
||||||
|
"description": "用于流程测试",
|
||||||
|
"price": 19900,
|
||||||
|
"duration": 30,
|
||||||
|
}
|
||||||
|
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 201, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = resp.JSON(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data := result["data"].(map[string]interface{})
|
||||||
|
packageID = uint(data["id"].(float64))
|
||||||
|
require.NotZero(t, packageID, "套餐 ID 不能为空")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Step 2: 分配给代理商
|
||||||
|
// 角色: 平台管理员
|
||||||
|
// 调用: POST /api/admin/shop-packages
|
||||||
|
// 输入: 套餐 ID + 店铺 ID
|
||||||
|
// 预期: 分配成功
|
||||||
|
//
|
||||||
|
// 依赖: Step 1 的 packageID
|
||||||
|
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
t.Run("Step2_分配套餐给代理商", func(t *testing.T) {
|
||||||
|
if packageID == 0 {
|
||||||
|
t.Skip("依赖 Step 1 创建的 packageID")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := env.AsSuperAdmin()
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"package_id": packageID,
|
||||||
|
"shop_id": shopID,
|
||||||
|
}
|
||||||
|
resp, err := client.Request("POST", "/api/admin/shop-packages", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Step 3: 代理商查看可售套餐
|
||||||
|
// 角色: 代理商
|
||||||
|
// 调用: GET /api/admin/shop-packages
|
||||||
|
// 预期: 列表包含刚分配的套餐
|
||||||
|
//
|
||||||
|
// 依赖: Step 2 的分配操作
|
||||||
|
// 破坏点:如果查询不按店铺过滤,代理商会看到其他店铺的套餐
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||||
|
if packageID == 0 {
|
||||||
|
t.Skip("依赖 Step 1 创建的 packageID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以代理商身份请求
|
||||||
|
client := env.AsShopAgent(shopID)
|
||||||
|
|
||||||
|
resp, err := client.Request("GET", "/api/admin/shop-packages", nil)
|
||||||
|
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{})
|
||||||
|
list := data["list"].([]interface{})
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, item := range list {
|
||||||
|
pkg := item.(map[string]interface{})
|
||||||
|
if uint(pkg["package_id"].(float64)) == packageID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "代理商应该能看到刚分配的套餐")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: 运行测试验证
|
||||||
|
|
||||||
|
生成测试后,立即运行验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 预期全部 FAIL(因为功能尚未实现)
|
||||||
|
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果测试 PASS**:
|
||||||
|
- 说明测试写得太弱,没有真正验证功能
|
||||||
|
- 需要加强测试或检查是否功能已存在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试模板规范
|
||||||
|
|
||||||
|
### 验收测试必须包含
|
||||||
|
|
||||||
|
1. **来源注释**:标明从哪个 spec 文件生成
|
||||||
|
2. **Scenario 注释**:完整的 GIVEN/WHEN/THEN/AND
|
||||||
|
3. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||||
|
4. **清晰的结构**:GIVEN → WHEN → THEN → AND 分块
|
||||||
|
|
||||||
|
### 流程测试必须包含
|
||||||
|
|
||||||
|
1. **来源注释**:标明从哪个 spec 文件生成
|
||||||
|
2. **参与者注释**:涉及哪些角色
|
||||||
|
3. **共享状态声明**:流程中需要传递的数据
|
||||||
|
4. **依赖声明**:每个 step 依赖哪些前置 step
|
||||||
|
5. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||||
|
|
||||||
|
### 破坏点注释示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||||
|
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
|
||||||
|
// 破坏点:如果查询不按 shop_id 过滤,此测试将失败(会返回其他店铺数据)
|
||||||
|
// 破坏点:如果状态机允许 "已使用" → "未分配" 的转换,此测试将失败
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 输出文件
|
||||||
|
|
||||||
|
| 来源 | 输出 |
|
||||||
|
|------|------|
|
||||||
|
| Spec Scenarios | `tests/acceptance/<capability>_acceptance_test.go` |
|
||||||
|
| Spec Business Flows | `tests/flows/<capability>_<flow>_flow_test.go` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **不要跳过 Scenario** - 每个 Scenario 必须对应一个测试用例
|
||||||
|
- **不要省略破坏点** - 每个测试必须有破坏点注释
|
||||||
|
- **不要硬编码 ID** - 使用共享变量传递流程中的 ID
|
||||||
|
- **不要忽略依赖** - 流程测试的 step 必须声明依赖
|
||||||
|
- **不要写永远 PASS 的测试** - 测试必须在功能缺失时 FAIL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他 Skills 的关系
|
||||||
|
|
||||||
|
| Skill | 关系 |
|
||||||
|
|-------|------|
|
||||||
|
| `openspec-continue-change` | 生成 spec 后可触发 gen-tests |
|
||||||
|
| `openspec-apply-change` | 实现时参考生成的测试 |
|
||||||
|
| `openspec-verify-change` | 验证时运行生成的测试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例交互
|
||||||
|
|
||||||
|
```
|
||||||
|
User: /opsx:gen-tests package-management
|
||||||
|
|
||||||
|
AI: 正在分析 change: package-management
|
||||||
|
|
||||||
|
找到 spec 文件: openspec/changes/package-management/specs/package/spec.md
|
||||||
|
|
||||||
|
解析结果:
|
||||||
|
- Scenarios: 5 个
|
||||||
|
- Business Flows: 2 个
|
||||||
|
|
||||||
|
生成测试文件:
|
||||||
|
|
||||||
|
1. tests/acceptance/package_acceptance_test.go
|
||||||
|
- Scenario_成功创建套餐
|
||||||
|
- Scenario_创建套餐参数校验失败
|
||||||
|
- Scenario_成功更新套餐
|
||||||
|
- Scenario_成功删除套餐
|
||||||
|
- Scenario_删除已分配套餐失败
|
||||||
|
|
||||||
|
2. tests/flows/package_lifecycle_flow_test.go
|
||||||
|
- Step1_平台管理员创建套餐
|
||||||
|
- Step2_分配套餐给代理商
|
||||||
|
- Step3_代理商查看可售套餐
|
||||||
|
- Step4_代理商创建订单
|
||||||
|
- Step5_套餐使用统计更新
|
||||||
|
|
||||||
|
3. tests/flows/package_allocation_flow_test.go
|
||||||
|
- Step1_批量分配套餐
|
||||||
|
- Step2_验证分配结果
|
||||||
|
- Step3_撤销部分分配
|
||||||
|
|
||||||
|
运行验证:
|
||||||
|
$ source .env.local && go test -v ./tests/acceptance/package_... ./tests/flows/package_...
|
||||||
|
|
||||||
|
--- FAIL: TestPackage_Acceptance (0.00s)
|
||||||
|
--- FAIL: TestPackage_Acceptance/Scenario_成功创建套餐 (0.00s)
|
||||||
|
package_acceptance_test.go:45: 404 != 201
|
||||||
|
--- FAIL: TestFlow_PackageLifecycle (0.00s)
|
||||||
|
--- FAIL: TestFlow_PackageLifecycle/Step1_平台管理员创建套餐 (0.00s)
|
||||||
|
package_lifecycle_flow_test.go:38: 404 != 201
|
||||||
|
|
||||||
|
✓ 所有测试预期 FAIL(功能未实现)
|
||||||
|
✓ 测试生成完成
|
||||||
|
|
||||||
|
下一步: /opsx:continue 生成 design 和 tasks
|
||||||
|
```
|
||||||
281
.claude/skills/openspec-lock-consensus/SKILL.md
Normal file
281
.claude/skills/openspec-lock-consensus/SKILL.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
name: openspec-lock-consensus
|
||||||
|
description: 锁定共识 - 在探索讨论后,将讨论结果锁定为正式共识文档。防止后续提案偏离讨论内容。
|
||||||
|
license: MIT
|
||||||
|
compatibility: Requires openspec CLI.
|
||||||
|
metadata:
|
||||||
|
author: junhong
|
||||||
|
version: "1.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 共识锁定 Skill
|
||||||
|
|
||||||
|
在 `/opsx:explore` 讨论后,使用此 skill 将讨论结果锁定为正式共识。共识文档是后续所有 artifact 的基础约束。
|
||||||
|
|
||||||
|
## 触发方式
|
||||||
|
|
||||||
|
```
|
||||||
|
/opsx:lock <change-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
或在探索结束后,AI 主动提议:
|
||||||
|
> "讨论已经比较清晰了,要锁定共识吗?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
### Step 1: 整理讨论要点
|
||||||
|
|
||||||
|
从对话中提取以下四个维度的共识:
|
||||||
|
|
||||||
|
| 维度 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **要做什么** | 明确的功能范围 | "支持批量导入 IoT 卡" |
|
||||||
|
| **不做什么** | 明确排除的内容 | "不支持实时同步,仅定时批量" |
|
||||||
|
| **关键约束** | 技术/业务限制 | "必须使用 Asynq 异步任务" |
|
||||||
|
| **验收标准** | 如何判断完成 | "导入 1000 张卡 < 30s" |
|
||||||
|
|
||||||
|
### Step 2: 使用 Question_tool 逐维度确认
|
||||||
|
|
||||||
|
**必须使用 Question_tool 进行结构化确认**,每个维度一个问题:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 示例:确认"要做什么"
|
||||||
|
Question_tool({
|
||||||
|
questions: [{
|
||||||
|
header: "确认:要做什么",
|
||||||
|
question: "以下是整理的功能范围,请确认:\n\n" +
|
||||||
|
"1. 功能点 A\n" +
|
||||||
|
"2. 功能点 B\n" +
|
||||||
|
"3. 功能点 C\n\n" +
|
||||||
|
"是否准确完整?",
|
||||||
|
options: [
|
||||||
|
{ label: "确认无误", description: "以上内容准确完整" },
|
||||||
|
{ label: "需要补充", description: "有遗漏的功能点" },
|
||||||
|
{ label: "需要删减", description: "有不应该包含的内容" }
|
||||||
|
],
|
||||||
|
multiple: false
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果用户选择"需要补充"或"需要删减"**:
|
||||||
|
- 用户会通过自定义输入提供修改意见
|
||||||
|
- 根据反馈更新列表,再次使用 Question_tool 确认
|
||||||
|
|
||||||
|
**确认流程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Question_tool: 确认"要做什么" │
|
||||||
|
│ ├── 用户选择"确认无误" → 进入下一维度 │
|
||||||
|
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Question_tool: 确认"不做什么" │
|
||||||
|
│ ├── 用户选择"确认无误" → 进入下一维度 │
|
||||||
|
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Question_tool: 确认"关键约束" │
|
||||||
|
│ ├── 用户选择"确认无误" → 进入下一维度 │
|
||||||
|
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Question_tool: 确认"验收标准" │
|
||||||
|
│ ├── 用户选择"确认无误" → 生成 consensus.md │
|
||||||
|
│ └── 用户选择其他/自定义 → 修改后重新确认 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 生成 consensus.md
|
||||||
|
|
||||||
|
所有维度确认后,创建文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 change 是否存在
|
||||||
|
openspec list --json
|
||||||
|
|
||||||
|
# 如果 change 不存在,先创建
|
||||||
|
# openspec new <change-name>
|
||||||
|
|
||||||
|
# 写入 consensus.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件路径**: `openspec/changes/<change-name>/consensus.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question_tool 使用规范
|
||||||
|
|
||||||
|
### 每个维度的问题模板
|
||||||
|
|
||||||
|
**1. 要做什么**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
header: "确认:要做什么",
|
||||||
|
question: "以下是整理的【功能范围】:\n\n" +
|
||||||
|
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||||
|
"\n\n请确认是否准确完整?",
|
||||||
|
options: [
|
||||||
|
{ label: "确认无误", description: "功能范围准确完整" },
|
||||||
|
{ label: "需要补充", description: "有遗漏的功能点" },
|
||||||
|
{ label: "需要删减", description: "有不应该包含的内容" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 不做什么**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
header: "确认:不做什么",
|
||||||
|
question: "以下是明确【排除的内容】:\n\n" +
|
||||||
|
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||||
|
"\n\n请确认是否正确?",
|
||||||
|
options: [
|
||||||
|
{ label: "确认无误", description: "排除范围正确" },
|
||||||
|
{ label: "需要补充", description: "还有其他需要排除的" },
|
||||||
|
{ label: "需要删减", description: "有些不应该排除" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 关键约束**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
header: "确认:关键约束",
|
||||||
|
question: "以下是【关键约束】:\n\n" +
|
||||||
|
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||||
|
"\n\n请确认是否正确?",
|
||||||
|
options: [
|
||||||
|
{ label: "确认无误", description: "约束条件正确" },
|
||||||
|
{ label: "需要补充", description: "还有其他约束" },
|
||||||
|
{ label: "需要修改", description: "约束描述不准确" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 验收标准**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
header: "确认:验收标准",
|
||||||
|
question: "以下是【验收标准】(必须可测量):\n\n" +
|
||||||
|
items.map((item, i) => `${i+1}. ${item}`).join('\n') +
|
||||||
|
"\n\n请确认是否正确?",
|
||||||
|
options: [
|
||||||
|
{ label: "确认无误", description: "验收标准清晰可测量" },
|
||||||
|
{ label: "需要补充", description: "还有其他验收标准" },
|
||||||
|
{ label: "需要修改", description: "标准不够清晰或无法测量" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理用户反馈
|
||||||
|
|
||||||
|
当用户选择非"确认无误"选项或提供自定义输入时:
|
||||||
|
|
||||||
|
1. 解析用户的修改意见
|
||||||
|
2. 更新对应维度的内容
|
||||||
|
3. 再次使用 Question_tool 确认更新后的内容
|
||||||
|
4. 重复直到用户选择"确认无误"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## consensus.md 模板
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 共识文档
|
||||||
|
|
||||||
|
**Change**: <change-name>
|
||||||
|
**确认时间**: <timestamp>
|
||||||
|
**确认人**: 用户
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 要做什么
|
||||||
|
|
||||||
|
- [x] 功能点 A(已确认)
|
||||||
|
- [x] 功能点 B(已确认)
|
||||||
|
- [x] 功能点 C(已确认)
|
||||||
|
|
||||||
|
## 2. 不做什么
|
||||||
|
|
||||||
|
- [x] 排除项 A(已确认)
|
||||||
|
- [x] 排除项 B(已确认)
|
||||||
|
|
||||||
|
## 3. 关键约束
|
||||||
|
|
||||||
|
- [x] 技术约束 A(已确认)
|
||||||
|
- [x] 业务约束 B(已确认)
|
||||||
|
|
||||||
|
## 4. 验收标准
|
||||||
|
|
||||||
|
- [x] 验收标准 A(已确认)
|
||||||
|
- [x] 验收标准 B(已确认)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 讨论背景
|
||||||
|
|
||||||
|
<简要总结讨论的核心问题和解决方向>
|
||||||
|
|
||||||
|
## 关键决策记录
|
||||||
|
|
||||||
|
| 决策点 | 选择 | 原因 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 决策 1 | 选项 A | 理由... |
|
||||||
|
| 决策 2 | 选项 B | 理由... |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**签字确认**: 用户已通过 Question_tool 逐条确认以上内容
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续流程绑定
|
||||||
|
|
||||||
|
### Proposal 生成时
|
||||||
|
|
||||||
|
`/opsx:continue` 生成 proposal 时,**必须**:
|
||||||
|
|
||||||
|
1. 读取 `consensus.md`
|
||||||
|
2. 确保 proposal 的 Capabilities 覆盖"要做什么"中的每一项
|
||||||
|
3. 确保 proposal 不包含"不做什么"中的内容
|
||||||
|
4. 确保 proposal 遵守"关键约束"
|
||||||
|
|
||||||
|
### 验证机制
|
||||||
|
|
||||||
|
如果 proposal 与 consensus 不一致,输出警告:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ Proposal 验证警告:
|
||||||
|
|
||||||
|
共识中"要做什么"但 Proposal 未提及:
|
||||||
|
- 功能点 C
|
||||||
|
|
||||||
|
共识中"不做什么"但 Proposal 包含:
|
||||||
|
- 排除项 A
|
||||||
|
|
||||||
|
建议修正 Proposal 或更新共识。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **必须使用 Question_tool** - 不要用纯文本确认
|
||||||
|
- **逐维度确认** - 四个维度分开确认,不要合并
|
||||||
|
- **不要跳过确认** - 每个维度都必须让用户明确确认
|
||||||
|
- **不要自作主张** - 只整理讨论中明确提到的内容
|
||||||
|
- **避免模糊表述** - "尽量"、"可能"、"考虑"等词汇需要明确化
|
||||||
|
- **验收标准必须可测量** - 避免"性能要好"这类无法验证的标准
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他 Skills 的关系
|
||||||
|
|
||||||
|
| Skill | 关系 |
|
||||||
|
|-------|------|
|
||||||
|
| `openspec-explore` | 探索结束后触发 lock |
|
||||||
|
| `openspec-new-change` | lock 后触发 new(如果 change 不存在)|
|
||||||
|
| `openspec-continue-change` | 生成 proposal 时读取 consensus 验证 |
|
||||||
|
| `openspec-generate-acceptance-tests` | 从 consensus 的验收标准生成测试骨架 |
|
||||||
19
.mcp.json
Normal file
19
.mcp.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"postgres": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"DATABASE_URI",
|
||||||
|
"crystaldba/postgres-mcp",
|
||||||
|
"--access-mode=restricted"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URI": "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
CLAUDE.md
211
CLAUDE.md
@@ -86,9 +86,15 @@ Handler → Service → Store → Model
|
|||||||
- 使用统一错误码系统
|
- 使用统一错误码系统
|
||||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||||
|
|
||||||
|
#### 错误报错规范(必须遵守)
|
||||||
|
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||||
|
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||||
|
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||||
|
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||||
|
|
||||||
### 响应格式
|
### 响应格式
|
||||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||||
- 格式: `{code, message, data, timestamp}`
|
- 格式: `{code, msg, data, timestamp}`
|
||||||
|
|
||||||
### 常量管理
|
### 常量管理
|
||||||
- 所有常量定义在 `pkg/constants/`
|
- 所有常量定义在 `pkg/constants/`
|
||||||
@@ -128,10 +134,67 @@ Handler → Service → Store → Model
|
|||||||
|
|
||||||
## 测试要求
|
## 测试要求
|
||||||
|
|
||||||
- 核心业务逻辑(Service 层)测试覆盖率 ≥ 90%
|
### 测试金字塔(新)
|
||||||
- 所有 API 端点必须有集成测试
|
|
||||||
- 使用 table-driven tests
|
```
|
||||||
- 单元测试 < 100ms,集成测试 < 1s
|
┌─────────────┐
|
||||||
|
│ E2E 测试 │ ← 手动/自动化 UI(很少)
|
||||||
|
─┴─────────────┴─
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 业务流程测试 │ ← 15%:多 API 组合验证
|
||||||
|
│ tests/flows/ │ 来源:Spec Business Flow
|
||||||
|
─┴─────────────────┴─
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 验收测试 │ ← 30%:单 API 契约验证
|
||||||
|
│ tests/acceptance/ │ 来源:Spec Scenario
|
||||||
|
─┴─────────────────────┴─
|
||||||
|
┌───────────────────────────┐
|
||||||
|
│ 集成测试 │ ← 25%:组件集成
|
||||||
|
─┴───────────────────────────┴─
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 单元测试(精简) │ ← 30%:仅复杂逻辑
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三层测试体系
|
||||||
|
|
||||||
|
| 层级 | 测试类型 | 来源 | 验证什么 | 位置 |
|
||||||
|
|------|---------|------|---------|------|
|
||||||
|
| **L1** | 验收测试 | Spec Scenario | 单 API 契约 | `tests/acceptance/` |
|
||||||
|
| **L2** | 流程测试 | Spec Business Flow | 业务场景完整性 | `tests/flows/` |
|
||||||
|
| **L3** | 单元测试 | 复杂逻辑 | 算法/规则正确性 | 模块内 `*_test.go` |
|
||||||
|
|
||||||
|
### 验收测试规范
|
||||||
|
|
||||||
|
- **来源于 Spec**:每个 Scenario 对应一个测试用例
|
||||||
|
- **测试先于实现**:在功能实现前生成,预期全部 FAIL
|
||||||
|
- **必须有破坏点**:每个测试注释说明什么代码变更会导致失败
|
||||||
|
- **使用 IntegrationTestEnv**:不要 mock 依赖
|
||||||
|
|
||||||
|
详见:[tests/acceptance/README.md](tests/acceptance/README.md)
|
||||||
|
|
||||||
|
### 流程测试规范
|
||||||
|
|
||||||
|
- **来源于 Spec Business Flow**:每个 Flow 对应一个测试
|
||||||
|
- **跨 API 验证**:多个 API 调用的组合行为
|
||||||
|
- **状态共享**:流程中的数据在 steps 之间传递
|
||||||
|
- **依赖声明**:每个 step 声明依赖哪些前置 step
|
||||||
|
|
||||||
|
详见:[tests/flows/README.md](tests/flows/README.md)
|
||||||
|
|
||||||
|
### 单元测试精简规则
|
||||||
|
|
||||||
|
**保留**:
|
||||||
|
- ✅ 纯函数(计费计算、分佣算法)
|
||||||
|
- ✅ 状态机(订单状态流转)
|
||||||
|
- ✅ 复杂业务规则(层级校验、权限计算)
|
||||||
|
- ✅ 边界条件(时间、金额、精度)
|
||||||
|
|
||||||
|
**删除/不再写**:
|
||||||
|
- ❌ 简单 CRUD(已被验收测试覆盖)
|
||||||
|
- ❌ DTO 转换
|
||||||
|
- ❌ 配置读取
|
||||||
|
- ❌ 重复测试同一逻辑
|
||||||
|
|
||||||
### ⚠️ 测试真实性原则(严格遵守)
|
### ⚠️ 测试真实性原则(严格遵守)
|
||||||
|
|
||||||
@@ -165,6 +228,15 @@ handler.HandleIotCardImport(ctx, asynqTask) // 测试完整流程,验证真
|
|||||||
|
|
||||||
**详细规范**: [docs/testing/test-connection-guide.md](docs/testing/test-connection-guide.md)
|
**详细规范**: [docs/testing/test-connection-guide.md](docs/testing/test-connection-guide.md)
|
||||||
|
|
||||||
|
**⚠️ 运行测试必须先加载环境变量**:
|
||||||
|
```bash
|
||||||
|
# ✅ 正确
|
||||||
|
source .env.local && go test -v ./internal/service/xxx/...
|
||||||
|
|
||||||
|
# ❌ 错误(会因缺少配置而失败)
|
||||||
|
go test -v ./internal/service/xxx/...
|
||||||
|
```
|
||||||
|
|
||||||
**标准模板**:
|
**标准模板**:
|
||||||
```go
|
```go
|
||||||
func TestXxx(t *testing.T) {
|
func TestXxx(t *testing.T) {
|
||||||
@@ -182,6 +254,23 @@ func TestXxx(t *testing.T) {
|
|||||||
- `GetTestRedis(t)`: 获取全局 Redis 连接
|
- `GetTestRedis(t)`: 获取全局 Redis 连接
|
||||||
- `CleanTestRedisKeys(t, rdb)`: 自动清理测试 Redis 键
|
- `CleanTestRedisKeys(t, rdb)`: 自动清理测试 Redis 键
|
||||||
|
|
||||||
|
**集成测试环境**(HTTP API 测试):
|
||||||
|
```go
|
||||||
|
func TestAPI_Create(t *testing.T) {
|
||||||
|
env := testutils.NewIntegrationTestEnv(t)
|
||||||
|
|
||||||
|
t.Run("成功创建", func(t *testing.T) {
|
||||||
|
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/resources", jsonBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `NewIntegrationTestEnv(t)`: 创建完整测试环境(事务、Redis、App、Token)
|
||||||
|
- `AsSuperAdmin()`: 以超级管理员身份请求
|
||||||
|
- `AsUser(account)`: 以指定账号身份请求
|
||||||
|
|
||||||
**禁止使用(已移除)**:
|
**禁止使用(已移除)**:
|
||||||
- ❌ `SetupTestDB` / `TeardownTestDB` / `SetupTestDBWithStore`
|
- ❌ `SetupTestDB` / `TeardownTestDB` / `SetupTestDBWithStore`
|
||||||
|
|
||||||
@@ -227,6 +316,118 @@ func TestXxx(t *testing.T) {
|
|||||||
8. ✅ 文档更新计划
|
8. ✅ 文档更新计划
|
||||||
9. ✅ 中文优先
|
9. ✅ 中文优先
|
||||||
|
|
||||||
|
## Code Review 检查清单
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||||
|
- [ ] Handler 层参数校验不泄露细节
|
||||||
|
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||||
|
- [ ] 错误日志完整(包含上下文)
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||||
|
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||||
|
- [ ] 常量定义在 `pkg/constants/`
|
||||||
|
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||||
|
|
||||||
|
### 测试覆盖
|
||||||
|
- [ ] 核心业务逻辑测试覆盖率 ≥ 90%
|
||||||
|
- [ ] 所有 API 端点有集成测试
|
||||||
|
- [ ] 测试验证真实功能(不绕过核心逻辑)
|
||||||
|
|
||||||
|
### 文档和注释
|
||||||
|
- [ ] 所有注释使用中文
|
||||||
|
- [ ] 导出函数/类型有文档注释
|
||||||
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
|
### 越权防护规范
|
||||||
|
|
||||||
|
**适用场景**:任何涉及跨用户、跨店铺、跨企业的资源访问
|
||||||
|
|
||||||
|
**三层防护机制**:
|
||||||
|
|
||||||
|
1. **路由层中间件**(粗粒度拦截)
|
||||||
|
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
||||||
|
- 示例:
|
||||||
|
```go
|
||||||
|
group.Use(func(c *fiber.Ctx) error {
|
||||||
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
|
if userType == constants.UserTypeEnterprise {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限访问账号管理功能")
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Service 层业务检查**(细粒度验证)
|
||||||
|
- 使用 `middleware.CanManageShop(ctx, targetShopID, shopStore)` 验证店铺权限
|
||||||
|
- 使用 `middleware.CanManageEnterprise(ctx, targetEnterpriseID, enterpriseStore, shopStore)` 验证企业权限
|
||||||
|
- 类型级权限检查(如代理不能创建平台账号)
|
||||||
|
- 示例见 `internal/service/account/service.go`
|
||||||
|
|
||||||
|
3. **GORM Callback 自动过滤**(兜底)
|
||||||
|
- 已有实现,自动应用到所有查询
|
||||||
|
- 代理用户:`WHERE shop_id IN (自己店铺+下级店铺)`
|
||||||
|
- 企业用户:`WHERE enterprise_id = 当前企业ID`
|
||||||
|
- 无需手动调用
|
||||||
|
|
||||||
|
**统一错误返回**:
|
||||||
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
|
### 审计日志规范
|
||||||
|
|
||||||
|
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||||
|
|
||||||
|
**使用方式**:
|
||||||
|
|
||||||
|
1. **Service 层集成审计日志**:
|
||||||
|
```go
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
auditService AuditServiceInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SensitiveOperation(ctx context.Context, ...) error {
|
||||||
|
// 1. 执行业务操作
|
||||||
|
err := s.store.DoSomething(ctx, ...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 记录审计日志(异步)
|
||||||
|
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||||
|
OperatorID: middleware.GetUserIDFromContext(ctx),
|
||||||
|
OperationType: "operation_type",
|
||||||
|
OperationDesc: "操作描述",
|
||||||
|
BeforeData: beforeData, // 变更前数据
|
||||||
|
AfterData: afterData, // 变更后数据
|
||||||
|
RequestID: middleware.GetRequestIDFromContext(ctx),
|
||||||
|
IPAddress: middleware.GetIPFromContext(ctx),
|
||||||
|
UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **审计日志字段说明**:
|
||||||
|
- `operator_id`, `operator_type`, `operator_name`: 操作人信息(必填)
|
||||||
|
- `target_*`: 目标资源信息(可选)
|
||||||
|
- `operation_type`: 操作类型(create/update/delete/assign_roles等)
|
||||||
|
- `operation_desc`: 操作描述(中文,便于查看)
|
||||||
|
- `before_data`, `after_data`: 变更数据(JSON 格式)
|
||||||
|
- `request_id`, `ip_address`, `user_agent`: 请求上下文
|
||||||
|
|
||||||
|
3. **异步写入**:
|
||||||
|
- 审计日志使用 Goroutine 异步写入
|
||||||
|
- 写入失败不影响业务操作
|
||||||
|
- 失败时记录 Error 日志,包含完整审计信息
|
||||||
|
|
||||||
|
**示例参考**:`internal/service/account/service.go`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### ⚠️ 任务执行规范(必须遵守)
|
### ⚠️ 任务执行规范(必须遵守)
|
||||||
|
|
||||||
**提案中的 tasks.md 是契约,不可擅自变更:**
|
**提案中的 tasks.md 是契约,不可擅自变更:**
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type DeviceResponse struct {
|
|||||||
StatusName string `json:"status_name" description:"状态名称"`
|
StatusName string `json:"status_name" description:"状态名称"`
|
||||||
BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"`
|
BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"`
|
||||||
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
|
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
|
||||||
|
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
|
||||||
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
||||||
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
|
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
|
||||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type StandaloneIotCardResponse struct {
|
|||||||
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
|
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
|
||||||
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
|
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
|
||||||
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
|
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
|
||||||
|
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
|
||||||
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
|
||||||
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
|
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`
|
||||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
|
|||||||
}
|
}
|
||||||
|
|
||||||
shopMap := s.loadShopData(ctx, devices)
|
shopMap := s.loadShopData(ctx, devices)
|
||||||
|
seriesMap := s.loadSeriesNames(ctx, devices)
|
||||||
bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices))
|
bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -109,7 +110,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
|
|||||||
|
|
||||||
list := make([]*dto.DeviceResponse, 0, len(devices))
|
list := make([]*dto.DeviceResponse, 0, len(devices))
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
item := s.toDeviceResponse(device, shopMap, bindingCounts)
|
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts)
|
||||||
list = append(list, item)
|
list = append(list, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +139,13 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
||||||
|
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
|
||||||
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.toDeviceResponse(device, shopMap, bindingCounts), nil
|
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByDeviceNo 通过设备号获取设备详情
|
// GetByDeviceNo 通过设备号获取设备详情
|
||||||
@@ -157,12 +159,13 @@ func (s *Service) GetByDeviceNo(ctx context.Context, deviceNo string) (*dto.Devi
|
|||||||
}
|
}
|
||||||
|
|
||||||
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
||||||
|
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
|
||||||
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.toDeviceResponse(device, shopMap, bindingCounts), nil
|
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||||
@@ -432,6 +435,29 @@ func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map
|
|||||||
return shopMap
|
return shopMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadSeriesNames(ctx context.Context, devices []*model.Device) map[uint]string {
|
||||||
|
seriesIDs := make([]uint, 0)
|
||||||
|
seriesIDSet := make(map[uint]bool)
|
||||||
|
|
||||||
|
for _, device := range devices {
|
||||||
|
if device.SeriesID != nil && *device.SeriesID > 0 && !seriesIDSet[*device.SeriesID] {
|
||||||
|
seriesIDs = append(seriesIDs, *device.SeriesID)
|
||||||
|
seriesIDSet[*device.SeriesID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesMap := make(map[uint]string)
|
||||||
|
if len(seriesIDs) > 0 {
|
||||||
|
var seriesList []model.PackageSeries
|
||||||
|
s.db.WithContext(ctx).Where("id IN ?", seriesIDs).Find(&seriesList)
|
||||||
|
for _, series := range seriesList {
|
||||||
|
seriesMap[series.ID] = series.SeriesName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seriesMap
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) {
|
func (s *Service) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) {
|
||||||
result := make(map[uint]int64)
|
result := make(map[uint]int64)
|
||||||
if len(deviceIDs) == 0 {
|
if len(deviceIDs) == 0 {
|
||||||
@@ -458,7 +484,7 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
|
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
|
||||||
resp := &dto.DeviceResponse{
|
resp := &dto.DeviceResponse{
|
||||||
ID: device.ID,
|
ID: device.ID,
|
||||||
DeviceNo: device.DeviceNo,
|
DeviceNo: device.DeviceNo,
|
||||||
@@ -484,6 +510,10 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string
|
|||||||
resp.ShopName = shopMap[*device.ShopID]
|
resp.ShopName = shopMap[*device.ShopID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if device.SeriesID != nil && *device.SeriesID > 0 {
|
||||||
|
resp.SeriesName = seriesMap[*device.SeriesID]
|
||||||
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,10 +109,11 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
|
|||||||
}
|
}
|
||||||
|
|
||||||
shopMap := s.loadShopNames(ctx, cards)
|
shopMap := s.loadShopNames(ctx, cards)
|
||||||
|
seriesMap := s.loadSeriesNames(ctx, cards)
|
||||||
|
|
||||||
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
|
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
|
||||||
for _, card := range cards {
|
for _, card := range cards {
|
||||||
item := s.toStandaloneResponse(card, shopMap)
|
item := s.toStandaloneResponse(card, shopMap, seriesMap)
|
||||||
list = append(list, item)
|
list = append(list, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +142,8 @@ func (s *Service) GetByICCID(ctx context.Context, iccid string) (*dto.IotCardDet
|
|||||||
}
|
}
|
||||||
|
|
||||||
shopMap := s.loadShopNames(ctx, []*model.IotCard{card})
|
shopMap := s.loadShopNames(ctx, []*model.IotCard{card})
|
||||||
standaloneResp := s.toStandaloneResponse(card, shopMap)
|
seriesMap := s.loadSeriesNames(ctx, []*model.IotCard{card})
|
||||||
|
standaloneResp := s.toStandaloneResponse(card, shopMap, seriesMap)
|
||||||
|
|
||||||
return &dto.IotCardDetailResponse{
|
return &dto.IotCardDetailResponse{
|
||||||
StandaloneIotCardResponse: *standaloneResp,
|
StandaloneIotCardResponse: *standaloneResp,
|
||||||
@@ -171,7 +173,30 @@ func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map
|
|||||||
return shopMap
|
return shopMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string) *dto.StandaloneIotCardResponse {
|
func (s *Service) loadSeriesNames(ctx context.Context, cards []*model.IotCard) map[uint]string {
|
||||||
|
seriesIDs := make([]uint, 0)
|
||||||
|
seriesIDSet := make(map[uint]bool)
|
||||||
|
|
||||||
|
for _, card := range cards {
|
||||||
|
if card.SeriesID != nil && *card.SeriesID > 0 && !seriesIDSet[*card.SeriesID] {
|
||||||
|
seriesIDs = append(seriesIDs, *card.SeriesID)
|
||||||
|
seriesIDSet[*card.SeriesID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesMap := make(map[uint]string)
|
||||||
|
if len(seriesIDs) > 0 {
|
||||||
|
var seriesList []model.PackageSeries
|
||||||
|
s.db.WithContext(ctx).Where("id IN ?", seriesIDs).Find(&seriesList)
|
||||||
|
for _, series := range seriesList {
|
||||||
|
seriesMap[series.ID] = series.SeriesName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seriesMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string, seriesMap map[uint]string) *dto.StandaloneIotCardResponse {
|
||||||
resp := &dto.StandaloneIotCardResponse{
|
resp := &dto.StandaloneIotCardResponse{
|
||||||
ID: card.ID,
|
ID: card.ID,
|
||||||
ICCID: card.ICCID,
|
ICCID: card.ICCID,
|
||||||
@@ -203,6 +228,10 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str
|
|||||||
resp.ShopName = shopMap[*card.ShopID]
|
resp.ShopName = shopMap[*card.ShopID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if card.SeriesID != nil && *card.SeriesID > 0 {
|
||||||
|
resp.SeriesName = seriesMap[*card.SeriesID]
|
||||||
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user