Просмотр исходного кода

test(EVD-7): 报告闭环 23 SC 三类流 e2e 证据补建 | 25 用例全绿(正常8/异常9/权限8) + 契约 JUnit 82/82 复验 | 新增 report-closedloop-contract.spec.js、CFC_API_BASE 本地复验回退、automation-run 证据文档

Sisyphus Agent 5 дней назад
Родитель
Сommit
cd35657f69

+ 10 - 0
docs/tdd/dev/evidence-report-closedloop-evd7-20261107.md

@@ -139,3 +139,13 @@ Tests run: 3,  Failures: 0, Errors: 0, Skipped: 0 - ReportParserProviderConnectC
 - `cfc-backend/src/test/java/com/etotem/cfc/unit/ReportParserProviderConnectContractTest.java`(SC-S-09,本次新增)
 - `cfc-langgraph/tests_contract/test_growth_report_contract.py`(SC-B-04/B-11,本次新增;置于 `tests_contract/` 避免 `tests/conftest.py` 引入 `app.main`→`faster_whisper`→`ctranslate2` 原生 DLL 依赖)
 - `docs/tdd/dev/evidence-report-closedloop-evd7-20261107.md`(本记录)
+
+## 8. EVD-7 后续补强(2026-11-07)
+
+EVD-7 在 EVD-7R 契约 JUnit 基础上补建 **API 级 e2e 三类流**,详见
+`docs/tdd/test/automation-run-evd7-report-closedloop-2026-11-07.md`:
+
+- 新增 `tests/e2e/api/report-closedloop-contract.spec.js`(25 用例:正常流 8 / 异常流 9 / 权限流 8)。
+- 执行环境:本地 `cfc-backend` @ 9082 + 测试库 `192.168.16.251:3306/zxyj`(远端 `cfc.iwintrue.com` 本窗口 DNS 不可解析)。
+- 实测结果:**e2e 25/25 通过(10.5s)**;同日复跑本批 11 类契约 JUnit → **82/82 通过**。
+- 未修改任何 `src/main` 业务实现;仅新增测试资产与证据文档。

+ 148 - 0
docs/tdd/test/automation-run-evd7-report-closedloop-2026-11-07.md

@@ -0,0 +1,148 @@
+# EVD-7 报告闭环批次(23 SC)自动化执行记录 · 三类流 e2e + 契约复验
+
+> 归属:契约补建 P0「报告闭环 · 23 SC」· 会话:EVD-7(承接 EVD-7R 契约 JUnit)· 日期:2026-11-07
+> 开工包:`docs/tdd/orchestration/report-closedloop-kickoff-20261106.md`
+> 前置证据:`docs/tdd/dev/evidence-report-closedloop-evd7-20261107.md`(EVD-7R:11 类 JUnit 82 用例 + LangGraph pytest 8 用例)
+> 本记录只补**执行证据**:新增 API 级 e2e spec + 复跑结果 + 环境说明,未改任何业务实现代码。
+
+## 1. 执行环境与基线
+
+| 项 | 内容 |
+|---|---|
+| 工作树 | `C:\Users\Administrator\AppData\Local\Temp\opencode\cfc-evd7`(隔离 worktree) |
+| 分支 | `feature/evd7-report-closedloop-contract`(基于 `origin/cfclub`) |
+| 基线 | `origin/cfclub` @ `7ecb11d02`(开工时 HEAD,`git merge-base --is-ancestor 7ecb11d02 HEAD` → exit 0) |
+| 后端 | 本地 `cfc-backend` @ `http://127.0.0.1:9082`(`mvn spring-boot:run`,实际连测试库 `192.168.16.251:3306/zxyj`) |
+| 远端 | `http://cfc.iwintrue.com` 在本窗口 **DNS 不可解析**(`未能解析远程名称`),故远端 e2e 不可执行;改用本地真实后端 + 真实测试库 |
+| 数据库口径 | 仅测试库,不代表生产;用例只自注册临时账号、只读写自身数据 |
+
+> 说明:EVD-5 的 API e2e 记录跑在远端 `cfc.iwintrue.com`。本轮远端不可达,为拿到**可复现的真实 HTTP 证据**,
+> 在隔离 worktree 启动本地后端(端口 9082,未触碰他人正在运行的进程),并用 `CFC_API_BASE` 指向本地运行同一份 spec。
+> `playwright.config.api.js` 仅新增 `process.env.CFC_API_BASE || 'http://cfc.iwintrue.com'` 的默认值回退,远端行为不变。
+
+## 2. 契约 JUnit 复验(确定性,纯 Mockito / 无 Spring / 无 DB)
+
+命令:
+
+```bash
+cd cfc-backend
+mvn -q -DskipTests test-compile          # EXIT=0
+mvn -DskipTests=false -Dtest='HealthReportAuditContractTest,ReportViewPermissionContractTest,ReportShareGrantContractTest,ReportTransferContractTest,DanReportUploadContractTest,AssessmentComparisonContractTest,GlobalSearchScP82ContractTest,ReportParserAdminContractTest,ReportConfirmRefreshFiveDimContractTest,ReportDietRecommendContractTest,ReportParserProviderConnectContractTest' surefire:test
+```
+
+实测输出:
+
+```
+Tests run: 6,  Failures: 0, Errors: 0, Skipped: 0 - AssessmentComparisonContractTest
+Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 - DanReportUploadContractTest
+Tests run: 5,  Failures: 0, Errors: 0, Skipped: 0 - GlobalSearchScP82ContractTest
+Tests run: 8,  Failures: 0, Errors: 0, Skipped: 0 - HealthReportAuditContractTest
+Tests run: 3,  Failures: 0, Errors: 0, Skipped: 0 - ReportConfirmRefreshFiveDimContractTest
+Tests run: 8,  Failures: 0, Errors: 0, Skipped: 0 - ReportDietRecommendContractTest
+Tests run: 5,  Failures: 0, Errors: 0, Skipped: 0 - ReportParserAdminContractTest
+Tests run: 3,  Failures: 0, Errors: 0, Skipped: 0 - ReportParserProviderConnectContractTest
+Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 - ReportShareGrantContractTest
+Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 - ReportTransferContractTest
+Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 - ReportViewPermissionContractTest
+Tests run: 82, Failures: 0, Errors: 0, Skipped: 0
+BUILD SUCCESS
+```
+
+结论:**82/82 通过**,与 EVD-7R 记录一致(本轮重新实跑,非引用旧结果)。
+
+## 3. API 级 e2e 执行(真实 HTTP + 真实测试库)
+
+新增 spec:`tests/e2e/api/report-closedloop-contract.spec.js`(25 用例)
+
+命令:
+
+```bash
+cd tests
+npm ci
+# 远端可用时省略 CFC_API_BASE
+CFC_API_BASE=http://127.0.0.1:9082 npx playwright test api/report-closedloop-contract.spec.js \
+  -c playwright.config.api.js --project=desktop
+```
+
+实测输出:
+
+```
+25 passed (10.5s)
+```
+
+按流分布:
+
+| 流 | 用例数 | 覆盖断言 |
+|---|---:|---|
+| 正常流 | 8 | 报告列表/家庭报告/趋势结构/授权-查询-撤销闭环/DAN 列表/解析类型链接/转交列表/teacher 代传闸门 |
+| 异常流 | 9 | 缺 memberId、缺 reportId、reportId 不存在、缺 filePath、缺授权参数、无两次测评、缺 uploadId、缺 requestId、草稿不存在 |
+| 权限流 | 8 | 无 token 401(报告/转交/代传)、非法 token 401(DAN/解析)、家长非代传角色→无权、非草稿所有者→无权确认、跨家庭成员查询→空列表不泄露 |
+
+> 首轮执行 23 passed / 2 failed,均为**断言口径写错**而非实现缺陷:
+> ① 转交审批缺参实际文案为「申请ID不能为空」(首轮写成「请求ID不能为空」);
+> ② 原「teacher 查授权列表应被拒」不成立——`direct-register` 的 teacher 会自建家庭并成为该家庭管理员,返回自身家庭空列表 200。
+> 修正为「跨家庭成员报告查询返回空列表」后 25/25 全绿(修正只改 spec,未改实现)。
+
+## 4. LangGraph 结构契约(SC-B-04/B-11)
+
+EVD-7R 已实跑 `cfc-langgraph/tests_contract/test_growth_report_contract.py` → **8 passed**。
+本轮隔离 worktree **无 `.venv`、系统 Python 无 `langgraph` 依赖**,未重复执行;测试文件未改动,证据沿用 EVD-7R 记录。
+
+## 5. 23 SC → 三类流证据矩阵(JUnit + e2e)
+
+| SC | 角色 | 场景 | JUnit | e2e | 三类流 |
+|---|---|---|---|---|---|
+| SC-A-02 | 平台运营 | 报告解析中台(类型/聚类) | `ReportParserAdminContractTest` | 解析类型链接 + 缺 filePath | ✅/✅/✅ |
+| SC-A-16 | 平台运营 | 解析自学习/未知类型标注 | `ReportParserAdminContractTest` | 解析类型链接 + 缺 filePath | ✅/✅/✅ |
+| SC-B-04 | AI 教练 | 成长报告智能解读 | — | pytest(EVD-7R) | ✅/—/✅ |
+| SC-B-11 | AI 教练 | 报告细节追问 | — | pytest(EVD-7R) | ✅/✅/✅ |
+| SC-C-06 | 孩子 | 查看能量与报告(只读) | `ReportViewPermissionContractTest` | 报告列表 + 跨成员隔离 | ✅/✅/✅ |
+| SC-C-07 | 孩子 | 查看 DAN 测评报告 | `DanReportUploadContractTest` | DAN 列表 + 非法 token 401 | ✅/✅/✅ |
+| SC-P-24 | 家长 | 多报告管理/对比 | `AssessmentComparisonContractTest` + `ReportViewPermissionContractTest` | 报告列表/家庭报告/指标缺参 | ✅/✅/✅ |
+| SC-P-25 | 家长 | 报告趋势查看 | `ReportViewPermissionContractTest` | 趋势结构 + 缺 memberId | ✅/✅/✅ |
+| SC-P-26 | 家长 | 基于报告饮食推荐 | `ReportDietRecommendContractTest` | —(JUnit 覆盖) | ✅/✅/✅ |
+| SC-P-39 | 家长 | 查看测评结果(DAN 报告) | `DanReportUploadContractTest` | DAN 列表 | ✅/✅/✅ |
+| SC-P-67 | 家长 | 报告代传 | `HealthReportAuditContractTest` | 代传无 token 401 + 非代传角色无权 | ✅/✅/✅ |
+| SC-P-82 | 家长 | 全局搜索 | `GlobalSearchScP82ContractTest` | —(MCP 需 `Mcp-Api-Key`,junit 覆盖) | ✅/✅/✅ |
+| SC-P-84 | 家长 | 分享报告给家人/规划师 | `ReportShareGrantContractTest` | 授权-查询-撤销闭环 + 缺参 + 跨成员隔离 | ✅/✅/✅ |
+| SC-P-91 | 家长 | 报告入库刷新五维 | `ReportConfirmRefreshFiveDimContractTest` | reportId 不存在 | ✅/✅/✅ |
+| SC-S-09 | 商品服务商 | 解析服务接入 | `ReportParserProviderConnectContractTest` | types/links + 非法 token 401 | ✅/✅/✅ |
+| SC-T-03 | 规划师 | 代传 + 确认入库 | `HealthReportAuditContractTest` | teacher 代传闸门 + 非所有者确认拒绝 | ✅/✅/✅ |
+| SC-T-11 | 规划师 | DAN 上传与解析 | `DanReportUploadContractTest` | —(上传依赖真实文件/JUnit 覆盖) | ✅/✅/✅ |
+| SC-T-12 | 规划师 | DAN 八类报告查看 | `DanReportUploadContractTest` | DAN 列表/对比缺参/非法 token | ✅/✅/✅ |
+| SC-T-21 | 规划师 | 报告对比分析 | `AssessmentComparisonContractTest` | 无两次测评→业务错误 | ✅/✅/✅ |
+| SC-T-23 | 规划师 | 报告代传(代客户传) | `HealthReportAuditContractTest` | 家长非代传角色无权 | ✅/✅/✅ |
+| SC-X-02 | 跨角色 | 报告闭环 | `HealthReportAuditContractTest` + `ReportTransferContractTest` | 报告列表/家庭报告/无 token 401 | ✅/✅/✅ |
+| SC-X-10 | 跨角色 | 报告对比趋势闭环 | `ReportViewPermissionContractTest` | 趋势结构 + 无两次测评 | ✅/✅/✅ |
+| SC-X-13 | 跨角色 | 报告代传跨角色闭环 | `ReportTransferContractTest` | 转交列表 + 缺 requestId + 无 token 401 | ✅/✅/✅ |
+
+> 三类流口径:正常流(主链路成功)/ 异常流(非法参数、空数据、不存在)/ 权限流(登录闸门、角色闸门、归属隔离)。
+> 「—」= 该 SC 的 e2e 分支不适用,由 JUnit/pytest 提供确定性证据(非缺口)。
+
+## 6. 观察记录(只登记,不修实现)
+
+| 观察 | 证据 | 说明 |
+|---|---|---|
+| `/api/health/report/audit/pending` 对任意登录用户返回**全平台**待审草稿(不限家庭/角色) | e2e 正常流 `audit/pending` 实测返回多个其他家庭(familyId 2009/2979)草稿 | 列表端点无家庭过滤;确认动作仍受 `validateDraftOwnership` 限制(非所有者确认被拒,已由 e2e 权限流验证)。建议后续治理列表可见范围 |
+| 代传确认对无权限角色返回业务码 500 +「无权」,而非 HTTP 403 | e2e `proxy-confirm` 权限流 | 与项目 `Result.error` 统一口径一致,非缺陷;登记供前端提示文案对齐 |
+
+## 7. 改动文件清单(仅测试资产 + 证据文档)
+
+```
+tests/e2e/api/report-closedloop-contract.spec.js      (新,25 用例 × 三类流)
+tests/playwright.config.api.js                        (改,baseURL 支持 CFC_API_BASE 本地复验回退)
+docs/tdd/test/automation-run-evd7-report-closedloop-2026-11-07.md(新,本记录)
+docs/tdd/dev/evidence-report-closedloop-evd7-20261107.md(改,追加第 8 节指向本记录)
+```
+
+未新增 DB 迁移、未修改任何 `src/main` 业务实现。
+
+## 8. 复验命令
+
+```bash
+# 契约 JUnit(本地确定性)
+cd cfc-backend && mvn -DskipTests=false -Dtest='HealthReportAuditContractTest,ReportViewPermissionContractTest,ReportShareGrantContractTest,ReportTransferContractTest,DanReportUploadContractTest,AssessmentComparisonContractTest,GlobalSearchScP82ContractTest,ReportParserAdminContractTest,ReportConfirmRefreshFiveDimContractTest,ReportDietRecommendContractTest,ReportParserProviderConnectContractTest' test
+
+# API e2e(远端可用时;或先本地启动 9082)
+cd tests && npx playwright test api/report-closedloop-contract.spec.js -c playwright.config.api.js --project=desktop
+```

+ 241 - 0
tests/e2e/api/report-closedloop-contract.spec.js

@@ -0,0 +1,241 @@
+/**
+ * EVD-7 报告闭环批次(23 SC)— 三类流 API 级 e2e
+ * ================================================================
+ * 覆盖 SC:SC-A-02/A-16、SC-C-06/C-07、SC-P-24/P-25/P-39/P-67/P-84/P-91、
+ *         SC-S-09、SC-T-03/T-11/T-12/T-21/T-23、SC-X-02/X-10/X-13。
+ * 与 cfc-backend/src/test/java/com/etotem/cfc/unit/*Report*ContractTest 契约 JUnit 互补:
+ *   - JUnit:Service/Controller 分支级契约(纯 Mockito,确定性);
+ *   - 本 spec:真实 HTTP + 真实 DB,验证「登录 → 家庭上下文 → 端点 → Result 外壳」主链路。
+ * 不在本 spec 覆盖:SC-B-04/B-11(LangGraph 报告解读/追问,见
+ *   cfc-langgraph/tests_contract/test_growth_report_contract.py)、SC-P-82(MCP JSON-RPC
+ *   需 Mcp-Api-Key,见 GlobalSearchScP82ContractTest)。
+ *
+ * 运行(默认远端;可用 CFC_API_BASE 指向本地 9082):
+ *   npx playwright test api/report-closedloop-contract.spec.js \
+ *     -c playwright.config.api.js --project=desktop
+ *   CFC_API_BASE=http://127.0.0.1:9082 npx playwright test api/report-closedloop-contract.spec.js \
+ *     -c playwright.config.api.js --project=desktop
+ *
+ * 安全约定:用例只自注册临时账号、只读写自己的数据;不打印 token / 手机号。
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api');
+
+test.describe('EVD-7 报告闭环 三类流(23 SC)', () => {
+  let parent;
+  let teacher;
+  let selfMemberId;
+  let teacherMemberId;
+
+  test.beforeAll(async ({ request }) => {
+    parent = await registerRole(request, 'parent', 'QA-EVD7-P' + uniqueSuffix());
+    const list = await apiPost(request, '/api/family/member/list', {}, parent.token);
+    expect(list.code, list.message).toBe(200);
+    const self = pickMemberByUserId(list.data, parent.userId);
+    expect(self, '成员列表应包含登录用户自身').toBeTruthy();
+    selfMemberId = self.id;
+
+    teacher = await registerRole(request, 'teacher', 'QA-EVD7-T' + uniqueSuffix());
+    const teacherList = await apiPost(request, '/api/family/member/list', {}, teacher.token);
+    expect(teacherList.code, teacherList.message).toBe(200);
+    const teacherSelf = pickMemberByUserId(teacherList.data, teacher.userId);
+    expect(teacherSelf, 'teacher 成员列表应包含自身').toBeTruthy();
+    teacherMemberId = teacherSelf.id;
+  });
+
+  // ==================== 正常流 ====================
+
+  test.describe('正常流', () => {
+    test('SC-C-06/P-24 报告列表按成员返回数组(只读)', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/list', { memberId: selfMemberId }, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(Array.isArray(r.data)).toBe(true);
+    });
+
+    test('SC-P-24/X-02 家庭报告列表返回数组', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/reports/family', {}, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(Array.isArray(r.data)).toBe(true);
+    });
+
+    test('SC-P-25/X-10 报告趋势返回 scores/dates 结构', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/nutrition/trend', { memberId: selfMemberId }, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(r.data && Array.isArray(r.data.scores)).toBe(true);
+      expect(r.data && Array.isArray(r.data.dates)).toBe(true);
+    });
+
+    test('SC-P-84 授权→查询→撤销闭环(成员级报告权限)', async ({ request }) => {
+      const grant = await apiPost(request, '/api/health/report/member-permission/grant',
+        { targetMemberId: selfMemberId, granteeUserId: parent.userId }, parent.token);
+      expect(grant.code, grant.message).toBe(200);
+      expect(grant.data).toBe(true);
+
+      const byMember = await apiPost(request,
+        '/api/health/report/member-permission/members/' + selfMemberId, {}, parent.token);
+      expect(byMember.code, byMember.message).toBe(200);
+      expect(byMember.data.map(String)).toContain(String(parent.userId));
+
+      const all = await apiPost(request, '/api/health/report/member-permission/list', {}, parent.token);
+      expect(all.code, all.message).toBe(200);
+      expect(Array.isArray(all.data)).toBe(true);
+
+      // 家庭管理员 visible-members 返回 null(全部可见语义);普通成员返回集合
+      const visible = await apiPost(request,
+        '/api/health/report/member-permission/visible-members', {}, parent.token);
+      expect(visible.code, visible.message).toBe(200);
+      expect(visible.data === null || Array.isArray(visible.data)).toBe(true);
+
+      const revoke = await apiPost(request, '/api/health/report/member-permission/revoke',
+        { targetMemberId: selfMemberId, granteeUserId: parent.userId }, parent.token);
+      expect(revoke.code, revoke.message).toBe(200);
+      expect(revoke.data).toBe(true);
+    });
+
+    test('SC-C-07/P-39/T-12 DAN 报告列表返回数组', async ({ request }) => {
+      const r = await apiPost(request, '/api/dan-report/list', { memberId: selfMemberId }, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(Array.isArray(r.data)).toBe(true);
+    });
+
+    test('SC-S-09/A-02 报告解析类型↔商品链接列表(服务商接入真实契约)', async ({ request }) => {
+      const r = await apiPost(request, '/api/report-parser/types/links', {}, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(Array.isArray(r.data)).toBe(true);
+    });
+
+    test('SC-X-13 报告转交请求列表返回 incoming/outgoing', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/transfer/list', {}, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(Array.isArray(r.data && r.data.incoming)).toBe(true);
+      expect(Array.isArray(r.data && r.data.outgoing)).toBe(true);
+    });
+
+    test('SC-T-03/T-23 代传确认(teacher 角色通过权限闸门后进入参数校验)', async ({ request }) => {
+      // teacher 属于代传上传者角色:通过 isProxyUploader 后,缺 draftId 返回业务错误而非「无权」
+      const r = await apiPost(request, '/api/health/report/proxy-confirm', {}, teacher.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('draftId');
+    });
+  });
+
+  // ==================== 异常流 ====================
+
+  test.describe('异常流', () => {
+    test('SC-P-25 趋势缺 memberId → memberId不能为空', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/nutrition/trend', {}, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('memberId不能为空');
+    });
+
+    test('SC-P-24 指标对比缺 reportId → reportId不能为空', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/indicators', {}, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('reportId不能为空');
+    });
+
+    test('SC-P-91 指标对比 reportId 不存在 → 报告不存在', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/indicators', { reportId: 999999999 }, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('报告不存在');
+    });
+
+    test('SC-A-02/A-16 解析检测缺 filePath → filePath 不能为空', async ({ request }) => {
+      const r = await apiPost(request, '/api/report-parser/detect', {}, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('filePath');
+    });
+
+    test('SC-P-84 授权缺参数 → targetMemberId/granteeUserId 不能为空', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/member-permission/grant', {}, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('不能为空');
+    });
+
+    test('SC-T-21/X-10 新成员无两次测评 → 对比返回业务错误', async ({ request }) => {
+      const r = await apiPost(request, '/api/assessment/compare/by-child', { memberId: selfMemberId }, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('两次');
+    });
+
+    test('SC-C-07/T-12 DAN 对比缺 uploadId → 缺少 uploadIdA 与 uploadIdB', async ({ request }) => {
+      const r = await apiPost(request, '/api/dan-report/compare', {}, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('uploadIdA');
+    });
+
+    test('SC-X-13 转交审批缺 requestId → 请求ID不能为空', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/transfer/approve', {}, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('申请ID不能为空');
+    });
+
+    test('SC-T-03/X-02 确认不存在的草稿 → 草稿不存在或已归档', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/audit/confirm', { draftId: 999999999 }, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('草稿不存在');
+    });
+  });
+
+  // ==================== 权限流 ====================
+
+  test.describe('权限流', () => {
+    test('SC-C-06/X-02 报告列表无 token → 401', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/list', { memberId: selfMemberId }, null);
+      expect(r.status).toBe(401);
+    });
+
+    test('SC-X-13 转交列表无 token → 401', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/transfer/list', {}, null);
+      expect(r.status).toBe(401);
+    });
+
+    test('SC-P-67/T-03 代传确认无 token → 401(LOGIN_REQUIRED_PATHS 闸门)', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/proxy-confirm', { draftId: 1 }, null);
+      expect(r.status).toBe(401);
+    });
+
+    test('SC-C-07/T-12 DAN 列表非法 token → 401', async ({ request }) => {
+      const r = await apiPost(request, '/api/dan-report/list', { memberId: selfMemberId }, 'invalid-token-value');
+      expect(r.status).toBe(401);
+    });
+
+    test('SC-S-09 解析类型链接非法 token → 401', async ({ request }) => {
+      const r = await apiPost(request, '/api/report-parser/types/links', {}, 'invalid-token-value');
+      expect(r.status).toBe(401);
+    });
+
+    test('SC-P-67/T-23 家长非代传角色调用代传确认 → 无权', async ({ request }) => {
+      const r = await apiPost(request, '/api/health/report/proxy-confirm', { draftId: 1 }, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('无权');
+    });
+
+    test('SC-T-03/X-02 非草稿所有者确认他人草稿 → 无权确认他人创建的草稿', async ({ request }) => {
+      const pending = await apiPost(request, '/api/health/report/audit/pending', { page: 1, size: 20 }, parent.token);
+      expect(pending.code, pending.message).toBe(200);
+      const othersDraft = (pending.data || []).find((d) => String(d.userId) !== String(parent.userId));
+      if (!othersDraft) {
+        test.skip(true, '当前测试库无他人待审草稿,跳过越权确认用例');
+        return;
+      }
+      const r = await apiPost(request, '/api/health/report/audit/confirm',
+        { draftId: othersDraft.id }, parent.token);
+      expect(r.code, r.message).not.toBe(200);
+      expect(String(r.message)).toContain('无权确认他人创建的草稿');
+    });
+
+    test('SC-P-84/C-06 跨家庭成员报告查询 → 空列表(成员级权限隔离,不泄露)', async ({ request }) => {
+      // parent 传 teacher 家庭的成员 ID:非本人、非本家庭管理员、未授权 → 返回空列表而非他人报告
+      const r = await apiPost(request, '/api/health/report/list', { memberId: teacherMemberId }, parent.token);
+      expect(r.code, r.message).toBe(200);
+      expect(Array.isArray(r.data)).toBe(true);
+      expect(r.data.length).toBe(0);
+
+      const dan = await apiPost(request, '/api/dan-report/list', { memberId: teacherMemberId }, parent.token);
+      expect(dan.code, dan.message).toBe(200);
+      expect(Array.isArray(dan.data)).toBe(true);
+      expect(dan.data.length).toBe(0);
+    });
+  });
+});

+ 2 - 1
tests/playwright.config.api.js

@@ -23,7 +23,8 @@ module.exports = {
   outputDir: path.join(__dirname, '.api-artifacts'),
   reporter: [['list']],
   use: {
-    baseURL: 'http://cfc.iwintrue.com',
+    // 默认远端;本地复验时用 CFC_API_BASE 指向已启动的 cfc-backend(如 http://127.0.0.1:9082)
+    baseURL: process.env.CFC_API_BASE || 'http://cfc.iwintrue.com',
     headless: true,
     ignoreHTTPSErrors: true,
     actionTimeout: 15000,