Explorar o código

docs(verify): T29-B2V 独立核验报告——commit 751145b9d 的 B2-1~B2-6 全部通过

- 核验对象: 751145b9d (feature/proxy-fix-b2-security), 基线 origin/cfclub (merge-base cddcacd6e)
- 结论: B2-1~B2-6 全部通过 (代码+测试37case+运行态三向核验), 无P0
- 问题: P1 合并冲突(COUPON-REG 055f6da25 与 UserService 冲突, 非B2引入) + P2 x3
- 运行态: 9083 enforcement / 9085 logging 双实例实测, 测试库无残留, 未新建业务行
Agnes Agent hai 2 semanas
pai
achega
a57af2660d
Modificáronse 1 ficheiros con 94 adicións e 0 borrados
  1. 94 0
      docs/2026-10-28-T29-B2-verification.md

+ 94 - 0
docs/2026-10-28-T29-B2-verification.md

@@ -0,0 +1,94 @@
+# T29-B2 报告代传安全修复 — 独立核验报告(verification)
+
+- **核验对象**:commit `751145b9d`(`feature/proxy-fix-b2-security`,标题「T29-B2: 报告代传功能修复·第二批——权限越权与安全策略收敛 (B2-1~B2-6)」)
+- **核验基线**:`origin/cfclub`(merge-base = `cddcacd6e8246d76ea20d145a02a0898c85f7be0`)
+- **规格依据**:`docs/superpowers/plans/2026-10-25-report-proxy-fix-plan.md` §批次 B2(B2-1 ~ B2-6)
+- **核验方式**:独立会话 —— 静态代码核查(codegraph/Read/diff)+ 单元测试 + 本地运行态实测(测试库 `192.168.16.251:3306/zxyj`)
+- **核验日期**:2026-10-28
+- **总体结论**:✅ **B2-1 ~ B2-6 全部通过**(代码级 + 测试级 + 运行态三向核验),无 P0;存在 1 项 P1 集成阻塞(非 B2 引入的合并冲突)与 3 项 P2 文档/一致性小问题(见「问题清单」)。
+
+---
+
+## 一、变更范围(diff 基线)
+
+`git diff --stat cddcacd6e 751145b9d` → **13 文件,+893 / −37**,全部为 B2 范围文件:
+
+| 类别 | 文件 |
+|------|------|
+| 业务代码(7) | `FamilyAccessInterceptor.java`、`JwtInterceptor.java`、`HealthReportController.java`、`family/FamilyMembersController.java`、`FamilyMemberService.java`、`HealthReportService.java`、`UserService.java` |
+| 测试(6) | `FamilyAccessInterceptorTest`、`JwtInterceptorLoginRequiredTest`、`HealthReportControllerProxyParseBindingTest`、`FamilyMembersControllerListTest`、`HealthReportServiceAuditConfirmTest`、`UserServiceEffectiveFamilyIdsTest` |
+
+- **无** `DatabaseInitializer.java` / `schema.sql` 变更 → B2 未引入任何 DB 迁移,符合规格「本任务不涉及新建表/迁移」。
+- **无** AGENTS.md 违规(接口统一 `@PostMapping`、MyBatis-Plus 参数化查询、`@Resource` 注入、未触碰 `children` 表废弃区)。
+
+---
+
+## 二、逐项验收表(B2-1 ~ B2-6)
+
+| # | 验收项 | 结论 | 代码证据 | 运行态证据 |
+|---|--------|:---:|----------|------------|
+| **B2-1** | `/api/family/member/list` 支持 familyId + 行级隔离;删除 `ensureUserFamily` 静默建家庭 | ✅ 通过 | `FamilyMembersController.listMembers`(:63-94)经 `ParamUtils.getLong` 读 `params.familyId` → `canAccessFamily()`(:101-107,admin/operator/finance 直通,否则 `getEffectiveFamilyIds(userId).contains(familyId)` → 403「无权访问该家庭数据」);三个分支的 `ensureUserFamily` 调用全部移除;`UserService.getVisibleMembers` familyId 为 null → 空列表;`FamilyMemberService.listMembersWithFamily` → 空 map、`listMembersOfFamily` → 空列表 | T1 空集用户 81315 + `{"familyId":2009}` → **HTTP 403** `{"code":403,"message":"无权访问该家庭数据"}`;T2 同用户不传 familyId → **HTTP 200** `{"code":200,"data":[]}`;T7 admin 1001 + `{"familyId":2009}` → 200 返回该家庭成员(平台直通)。**查库:0 条 family 行以 81315 为 creator/teacher/butler,0 条 family_members 归属 81315,`users.family_id` 仍 NULL → 未建任何家庭行** |
+| **B2-2** | `proxyParsePreview` 显式 familyId 绑定校验(不依赖拦截器) | ✅ 通过 | `HealthReportController.canProxyForFamily(userId, role, roles, familyId)`(:2696)在角色校验 + null 校验之后、`storeFile`/`createDraft` **之前**调用 → 不通过返回 `Result.error("无权为该家庭代传报告")`(code=500),无任何副作用 | T5 未绑定 teacher 81315 multipart proxy-parse + `familyId=2009`:9083(enforcement)→ **HTTP 403**(拦截器层);9085(`-Dfamily.access.mode=logging`,暴露控制器层)→ **HTTP 200** body `{"code":500,"message":"无权为该家庭代传报告"}` —— **第②层校验运行态实锤**,且在 storeFile/建草稿前,无文件落盘 |
+| **B2-3** | `proxyConfirm` 校验草稿 familyId ↔ subjectId 归属一致 + 仅创建者/admin 可确认 | ✅ 通过 | `HealthReportService.auditConfirm(draftId, subjectId, auditorId, isAdmin)`(:2056):`validateDraftOwnership`(非 admin 必须 `draft.getUserId()` 相等 → `IllegalArgumentException("无权确认他人创建的草稿")`)+ `validateSubjectFamily`(family_members.family_id vs draft.familyId,users.id 路径经 `resolveSubjectToMemberId` → `"所选成员不属于该草稿所属家庭"`);两校验均在**任何写操作之前**(:2063-2065 vs 写块 :2067+) | T9a teacher 非创建者确认 draft 111 → `{"code":500,"message":"确认失败: 无权确认他人创建的草稿"}`;T9b admin + 跨家庭 subjectId 1023(family 2)vs draft family 2009 → `{"code":500,"message":"确认失败: 所选成员不属于该草稿所属家庭"}`;**查库:draft 111 仍 `pending`、`confirmed_report_id` 仍 NULL,health_reports 无新增 → 校验先于写入** |
+| **B2-4** | `FamilyAccessInterceptor` 空有效家庭集拒绝;路由枚举逐条结论;回归不误伤无 familyId 请求 | ✅ 通过 | 平台角色直通(`"admin".equals(role) || AdminRoles.hasAnyPlatformRole(roles)`,PLATFORM_ROLES=admin/operator/finance)置于 `getEffectiveFamilyIds` 之前;`effectiveFamilyIds` 为 null/空 且 `collectFamilyIds` 非空 → enforcement 一律 403(:78-90);`requestedFamilyIds` 为空 → 放行(:74-76);`isEnforcementMode()` 默认 **enforcement**(`family.access.mode` 仅显式 `logging` 才关闭,:170);`getEffectiveFamilyIds` 加宽:own familyId + teacherFamilyIds + family_members 反查(仅 users.family_id==NULL 时,`.last("LIMIT 20")`)+ guide_families status='binding' | T1 空集 81315 + `familyId=2009` → 403(复现路径 ✓);T3 `?familyId=2009` 调 `/api/health/report/list` → 403;T3b `/api/dan-assessment/record/family` 空集 → 403、admin → 200;**回归**:T2 成员列表无 familyId → 200 `data:[]`、T8 `/api/tasks/today` 无 familyId → 200 `data:[]`、R1 反查队列用户 81371(family 2098,family_members 有行)+ `familyId=2098` → 200(自己家庭不被误伤)。路由枚举结论见第四节 |
+| **B2-5** | `proxy-parse` / `proxy-confirm` 移入 `LOGIN_REQUIRED_PATHS` → 无 token 直接 401 | ✅ 通过 | `JwtInterceptor.LOGIN_REQUIRED_PATHS`(:85-86)已含 `/api/health/report/proxy-parse` 与 `/api/health/report/proxy-confirm`(该数组语义 = 无 token 直接 401) | 无 token `POST proxy-parse` → **HTTP 401** `{"code":401,"message":"请先登录"}`;无 token `POST proxy-confirm` → **HTTP 401**(修复前为业务 code=500「无权限」) |
+| **B2-6** | 三层校验编排注释块 + 明确返回码 | ✅ 通过 | `proxyParsePreview` javadoc(:2712-2722)枚举:① 角色 `isProxyUploader` 失败 → code=500「无权限代上传报告…」;② familyId 绑定失败 → code=500「无权为该家庭代传报告」;③ 拦截器兜底 → HTTP 403 `{"code":403,"message":"无权访问该家庭数据"}` | ①②③ 三层均已在运行态分别实测命中(T5 ②、T1 ③、无 token 401 为 B2-5 层),返回码与注释一致 |
+
+**补充核验**:
+- **编译**:`mvn compile`(cfc-backend,Java 8 + Spring Boot 2.7.18)→ **BUILD SUCCESS**(1955 源文件)。
+- **单元测试**:6 个新增/扩展测试类 `mvn test -DskipTests=false` → **Tests run: 37, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS**(与 commit 声明一致)。
+- **SQL 注入扫描**:7 个业务变更文件的所有新增 DB 访问均为参数化 `LambdaQueryWrapper`(唯一常量 `.last("LIMIT 20")`);既有 `JdbcTemplate` 用法未被 diff 触碰 → 无注入面。
+
+---
+
+## 三、问题清单
+
+| 级别 | 问题 | 说明 | 处置建议 |
+|:---:|------|------|---------|
+| **P1** | 合并冲突(集成阻塞,非 B2 引入) | merge-base `751145b9d` vs `origin/cfclub` = `cddcacd6e`;COUPON-REG commit `055f6da25` 是 `origin/cfclub` 祖先但**不是** `751145b9d` 祖先,且改 `UserService.java` / `DatabaseInitializer.java` / `schema.sql` —— 与 B2 改动过的 `UserService.java` 直接冲突,另两文件因相邻上下文可能冲突 | 合入时以 `origin/cfclub` 为基 rebase 或 3-way merge,人工解决 `UserService` 冲突;**不属 B2 范围蔓延**,属集成编排事项 |
+| **P2** | B2-2 / B2-3 失败返回**业务 code=500**,非 HTTP 403 | 与 B2-4 拦截器的 HTTP 403 口径不一致(越权语义 403 更贴切) | 可选:改为 `Result.error(403, ...)` 或 `ResponseEntity`,统一越权返回码;不影响功能正确性 |
+| **P2** | `FamilyAccessInterceptor` 类注释陈旧 | :25 仍写「logging 模式(默认)」,但 `isEnforcementMode()`(:170)默认 **enforcement** —— 注释与实现相悖 | 更新 javadoc 默认模式说明 |
+| **P2** | `cfc-backend/config/application.yml` 覆盖 classpath `application.yml` 的 `jwt.secret` | git 跟踪的 `config/application.yml` 优先级高于 classpath(`file:./config/`),有效密钥为 `cfc-secret-key-2026-spring-boot-jwt-token` 而非 `${JWT_SECRET:cfc-prod-jwt-secret-2026-keep-it-safe!}` 默认值 | 部署脚注:该外部配置优先,改密钥需同步两处;建议在 `config/application.yml` 加注释说明 |
+
+> **备注**:未发现「正常业务依赖旧『空集放行』」的 P0(见第四节路由枚举结论 + R1/T8/T2 回归实测均未被误伤),故不触发规格「停下回报,不擅自白名单」。
+
+---
+
+## 四、B2-4 路由枚举结论(穷举带 familyId 的 controller 方法)
+
+穷举 `cfc-backend` 全部 controller 中引用 `familyId` 的方法(共 **231** 处),按 familyId **来源** 分类给出「空有效家庭集 + 携带 familyId」结论:
+
+| familyId 来源 | 代表路由 | 空集 + 带 familyId | 结论 |
+|--------------|---------|-------------------|:---:|
+| **客户端可控**(query/path/body 显式传) | query:`/api/health/report/proxy-parse`、`/api/assessment/plans/list`、`/api/wish/list`、`/api/dan-assessment/{order/family, record/current, record/family}`;path:`/api/guide/families/{familyId}/members`、`/api/guide/**/{familyId}/**`;body:`/api/family/member/list`、health report list/detail、family-platform 运营端点、orchestration、plan-review、innate-portrait 等 | → 一律 403(enforcement) | ✅ 安全收敛正确;这些正是需行级隔离的端点 |
+| **`@RequestAttribute("familyId")`**(JWT 注入,非客户端可控) | 大多数业务端点(subscription、diet、promotion、indicator、energy、health-plan 等) | familyId 由 JWT 从 `user.family_id` 注入,**与请求参数无关** → 拦截器 `collectFamilyIds` 不采集,不受空集 403 影响 | ✅ 不受影响(回归安全) |
+| **不携带 familyId 的既有请求** | 报告列表 / 成员列表 / 今日任务等 | `requestedFamilyIds` 为空 → 放行(:74-76) | ✅ 不被误伤(T2/T8/R1 实测) |
+
+- 前端 `cfc-frontend/pages/profile-extra/family-members.vue` 对 `/api/family/member/list` **不发送 `familyId`**(本地 `familyId: 0` 仅是页面数据默认值,不随请求发出)→ 前端无「会被新策略误伤」的路由。
+- 结论:**未发现正常业务依赖旧「空集放行」**,B2-4 全局策略变更不触发规格第 9 条停线条件。
+
+---
+
+## 五、测试数据与清理确认
+
+| 检查项 | 结果 |
+|--------|:---:|
+| `health_report_drafts` 总数 | 74(与运行态测试前一致,无新增草稿) |
+| `health_reports` 总数 | 22(无新增报告) |
+| draft 111 状态 | 仍 `pending` / `confirmed_report_id` NULL(B2-3 负向测试未写入) |
+| 以 81315 为 creator/teacher/butler 的 family 行 | 0(无静默建家庭) |
+| 归属 81315 的 family_members | 0 |
+| `users.family_id` for 81315 | 仍 NULL(未被篡改) |
+| 工作树 `uploads/` 新增文件 | 0(最新文件仍为 20260820,无代传文件落盘) |
+| `user_operation_logs` 验证审计行 | 已删除 id 63712–63730(共 16 行,主键范围 + 显式 id;未触碰其他会话行 62768) |
+
+> 本次核验**未创建**任何 users / family / family_members / draft / report 行;仅创建并已删除临时审计行。测试库无本次核验残留。复现账号 81315 / 81371 / 1001 均为**既有数据**,未修改。
+
+---
+
+## 六、核验环境与证据留存
+
+- **运行态**:本会话启动两实例 —— 9083(enforcement 默认)、9085(`-Dfamily.access.mode=logging`);均已停止,端口已释放,无残留 java 进程。
+- **临时脚本**:`jwt_make.ps1`(HS256,secret `cfc-secret-key-2026-spring-boot-jwt-token`)、`runtime_tests*.ps1`(逐次 HTTP 证据)、`route_enum*.py`(路由枚举)——均在 `%TEMP%\opencode`,核验后清理。
+- **JWT 密钥说明**:运行态实际生效密钥取自 git 跟踪的 `cfc-backend/config/application.yml`(优先于 classpath `application.yml`),已作为 P2 项记录。
+