diff --git a/.claude/commands/opsx/gen-tests.md b/.claude/commands/opsx/gen-tests.md new file mode 100644 index 0000000..62172c9 --- /dev/null +++ b/.claude/commands/opsx/gen-tests.md @@ -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 "" --json + ``` + 确认 specs artifact 已完成(`status: "done"`) + +3. **读取 spec 文件** + + 读取 `openspec/changes//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/_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/__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:** +**来源:** specs//spec.md + +### 生成的测试文件 + +**验收测试** (tests/acceptance/): +- _acceptance_test.go + - Scenario_xxx + - Scenario_yyy + +**流程测试** (tests/flows/): +- __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` diff --git a/.claude/skills/openspec-generate-acceptance-tests/SKILL.md b/.claude/skills/openspec-generate-acceptance-tests/SKILL.md new file mode 100644 index 0000000..4833adf --- /dev/null +++ b/.claude/skills/openspec-generate-acceptance-tests/SKILL.md @@ -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//specs//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/_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/__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/_acceptance_test.go` | +| Spec Business Flows | `tests/flows/__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 +``` diff --git a/.claude/skills/openspec-lock-consensus/SKILL.md b/.claude/skills/openspec-lock-consensus/SKILL.md new file mode 100644 index 0000000..a7d532b --- /dev/null +++ b/.claude/skills/openspec-lock-consensus/SKILL.md @@ -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 +``` + +或在探索结束后,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 + +# 写入 consensus.md +``` + +**文件路径**: `openspec/changes//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**: +**确认时间**: +**确认人**: 用户 + +--- + +## 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 的验收标准生成测试骨架 | diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..2f75365 --- /dev/null +++ b/.mcp.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 14c519b..7664288 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,9 +86,15 @@ Handler → Service → Store → Model - 使用统一错误码系统 - 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/` 的统一格式 -- 格式: `{code, message, data, timestamp}` +- 格式: `{code, msg, data, timestamp}` ### 常量管理 - 所有常量定义在 `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) +**⚠️ 运行测试必须先加载环境变量**: +```bash +# ✅ 正确 +source .env.local && go test -v ./internal/service/xxx/... + +# ❌ 错误(会因缺少配置而失败) +go test -v ./internal/service/xxx/... +``` + **标准模板**: ```go func TestXxx(t *testing.T) { @@ -182,6 +254,23 @@ func TestXxx(t *testing.T) { - `GetTestRedis(t)`: 获取全局 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` @@ -227,6 +316,118 @@ func TestXxx(t *testing.T) { 8. ✅ 文档更新计划 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 是契约,不可擅自变更:** diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index bf89f2c..48b7681 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -32,6 +32,7 @@ type DeviceResponse struct { StatusName string `json:"status_name" description:"状态名称"` BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"` SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` + SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"` AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"` ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index d0580b1..cc465b6 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -41,6 +41,7 @@ type StandaloneIotCardResponse struct { NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"` DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"` SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` + SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"` AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"` CreatedAt time.Time `json:"created_at" description:"创建时间"` diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 30d7424..26c1215 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -102,6 +102,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li } shopMap := s.loadShopData(ctx, devices) + seriesMap := s.loadSeriesNames(ctx, devices) bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices)) if err != nil { 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)) for _, device := range devices { - item := s.toDeviceResponse(device, shopMap, bindingCounts) + item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts) 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}) + seriesMap := s.loadSeriesNames(ctx, []*model.Device{device}) bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID}) if err != nil { return nil, err } - return s.toDeviceResponse(device, shopMap, bindingCounts), nil + return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil } // GetByDeviceNo 通过设备号获取设备详情 @@ -157,12 +159,13 @@ func (s *Service) GetByDeviceNo(ctx context.Context, deviceNo string) (*dto.Devi } shopMap := s.loadShopData(ctx, []*model.Device{device}) + seriesMap := s.loadSeriesNames(ctx, []*model.Device{device}) bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID}) if err != nil { 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 { @@ -432,6 +435,29 @@ func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map 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) { result := make(map[uint]int64) if len(deviceIDs) == 0 { @@ -458,7 +484,7 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint { 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{ ID: device.ID, DeviceNo: device.DeviceNo, @@ -484,6 +510,10 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string resp.ShopName = shopMap[*device.ShopID] } + if device.SeriesID != nil && *device.SeriesID > 0 { + resp.SeriesName = seriesMap[*device.SeriesID] + } + return resp } diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index ed21d6b..011cc12 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -109,10 +109,11 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot } shopMap := s.loadShopNames(ctx, cards) + seriesMap := s.loadSeriesNames(ctx, cards) list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) for _, card := range cards { - item := s.toStandaloneResponse(card, shopMap) + item := s.toStandaloneResponse(card, shopMap, seriesMap) 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}) - standaloneResp := s.toStandaloneResponse(card, shopMap) + seriesMap := s.loadSeriesNames(ctx, []*model.IotCard{card}) + standaloneResp := s.toStandaloneResponse(card, shopMap, seriesMap) return &dto.IotCardDetailResponse{ StandaloneIotCardResponse: *standaloneResp, @@ -171,7 +173,30 @@ func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map 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{ ID: card.ID, ICCID: card.ICCID, @@ -203,6 +228,10 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str resp.ShopName = shopMap[*card.ShopID] } + if card.SeriesID != nil && *card.SeriesID > 0 { + resp.SeriesName = seriesMap[*card.SeriesID] + } + return resp }