# Phase 13A V1.1 Recovery Completion Report — 婚恋咨询与婚前辅导 > 日期:2026-09-17 > 阶段:Phase 13A(Counseling & Premarital Guidance — REFACTOR/EXTEND) > 分支:`main` | Tag:`phase13a-recovery-2026-09-17` > 结论:**CLOSED(Gate 全项通过)** --- ## 一、计划目标与 Gate 计划出处:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 第 308-315 行。 | 计划项 | 要求 | 结论 | 证据 | |---|---|---|---| | 计划项 1 | 修复 Admin Vue 语法错误并注册 `/counseling` 路由,先使 type-check 失败转为通过 | ✅ 通过 | `router/index.ts:36` 已注册;`vue-tsc --noEmit` EXIT=0 | | 计划项 2 | 用户申请接口从认证主体取 userId;补幂等、取消、详情 owner 校验和合法状态迁移 | ✅ 通过 | D2/D5/D6 + `applyUsesAuthenticatedPrincipalUserId` / `applyIsIdempotentByKey` / `onlyOwnerCanCancel` 等 8 用例 | | 计划项 3 | 分配操作使用条件更新/版本号解决并发;Counselor 只能读取和操作 Assigned Service | ✅ 通过 | D3(`assignIfPending`)+ INV-012 四用例 | | 计划项 4 | Counseling Manager 可分配和查看必要摘要;普通管理员、红娘、内容编辑不得读取咨询正文 | ✅ 通过 | D4 + INV-013 七用例 | | 计划项 5 | 婚前辅导复用 counseling service type;邀请平台内伴侣必须由对方自主确认 | ✅ 通过 | INV-005 四用例 | | 计划项 6 | U55~U59 修复类型错误、API 契约、Loading/Empty/Error/Permission 状态和非医疗化文案 | ✅ 通过 | D7/D8 + 前端双项目 type-check EXIT=0 | **Gate:** 申请→分配→预约→服务→完成→反馈真实闭环通过;所有隐私负向用例通过。 | Gate 断言 | 证据 | 结果 | |---|---|---| | 真实闭环打通 | `fullLifecycleClosedLoop`:apply→assign→schedule→start→addFollowup→complete,全程真实落库,终态 `completed` 且 `completedAt` 非空 | ✅ | | INV-012(Counselor 只能操作自己的) | `nonAssignedCounselorCannotSchedule` / `nonAssignedCounselorCannotAddFollowup` / `nonAssignedCounselorCannotComplete` / `assignedListOnlyContainsOwnServices` | ✅ | | INV-013(红娘/普通运营不可读正文) | `matchmakerSeesSanitizedDetail` / `plainOperatorSeesSanitizedDetail` / `managerSeesSummaryButNotFollowups` / `strangerGetsNothing` / `listPathsAreSanitized` | ✅ | | INV-005(不得替用户确认) | `companionCanConfirmBySelf` / `staffCannotConfirmOnBehalfOfCompanion` / `companionConfirmOnlyForPremarital` / `companionConfirmIsIdempotent` | ✅ | | 并发安全 | `concurrentAssignDoesNotSilentlyOverwrite` | ✅ | --- ## 二、复验发现:counseling 模块存在**双实现**,其中一个是从未被调用的孤儿类 | 类 | 行数 | 注入点 | HTTP 端点可达 | 测试 | |---|---|---|---|---| | `CounselingService` | 167 | 有(Controller 注入) | 是 | 无直接测试 | | `CounselingServiceApp` | 280 | **零** | **否** | 仅 `CounselingDataScopeTest` 直接 `new` 它 | 历史 ✅ 声称「counseling 模块已实现并通过测试」,实际情况是: - **线上权威路径** `CounselingService` 只有 167 行,**没有任何测试**; - 唯一被测的 `CounselingServiceApp` 是**孤儿类**——无任何注入点、无 HTTP 端点可达。 它的 280 行业务逻辑在生产上**永远不会被执行**; - 即「测试覆盖了函数」≠「测试覆盖了运行时路径」——这是同一族陷阱的第七次复现。 **处理**:将 App 的流程合并入 `CounselingService` 并**删除 App**,连同仅 App 使用的 `CounselingDTO` / `CounselingRequest` / `FollowupDTO` 与测死代码的 `CounselingDataScopeTest`。 --- ## 三、缺陷清单(D1~D9) ### D1 — Critical(生产故障 · INV-013 泄露):咨询正文被写入日志 - **位置**:`CounselingServiceApp.java:81` - **代码**:`log.warn("咨询内容触发高危风险: userId={}, summary={}", userId, request.getUserRequestSummary())` - **后果**:用户自述的敏感咨询内容**原文进入应用日志**(落盘、进日志采集、被运维可见), 直接违反 INV-013「Matchmaker / 普通 operator 不能读取 Counseling 正文」的数据边界。 与 Phase 10 D2 属同一族。 - **修复**:合并入 `CounselingService.apply` 后改为只记 `serviceId` + `riskLevel`, **绝不记录 summary 原文**;`followups.content` 亦不落日志。 ### D2 — High(越权):申请接口的 userId 取自请求体而非认证主体 - **位置**:`CounselingServiceApp.createCounseling` - **后果**:攻击者可伪造 `userId` 为他人代提咨询申请,污染他人服务记录, 且可能借他人身份触发风险检测(把风险记到别人头上)。 `dataScopeGuard` 虽已注入但**未用于申请路径**。 - **修复**:Controller 强制 `principal.id()`,`apply()` 的 `userId` 只能来自认证主体。 ### D3 — High(并发):分配使用「先读后写」,并发下静默覆盖 - **位置**:`CounselingServiceApp.assignCounselor` - **代码形态**:`selectById` → `setStatus/setAssignedOperatorId` → `updateById` - **后果**:两个管理员**同时**把同一单分配给不同咨询师时,后写者**静默覆盖**先写者; 先被分配的咨询师可能已经收到通知并开始服务,造成真实责任错乱。 - **修复**:新增 `assignIfPending(serviceId, counselorId)` 条件更新 (`WHERE status='pending' AND assigned_operator_id IS NULL`), `affectedRows=0` 即明确报「已被分配或状态已变更」,不做盲目覆盖。 ### D4 — High(越权读):`counseling_manager` 成为 INV-013 的绕过通道 - **位置**:`getDetail` 的 manager 分支 - **后果**:manager 经 `getDetail` 可读取 `followups` 正文(跟进记录含最敏感内容)。 计划项 4 明确「Manager 可查看**必要摘要**」,不是全文。 - **修复**:manager 分支只加回 `userRequestSummary`(供分派判断), **不装配 followups**。 ### D5 — Medium(状态迁移缺失):无合法状态机 - **后果**:任意状态可被改成任意状态。例如「已完成」的服务可被再次「取消」写回, 历史统计与责任归属被破坏。 - **修复**:新增 `ALLOWED_TRANSITIONS` 状态机 + `assertTransition`, 终态(`completed`/`cancelled`/`closed`)无出边。 ### D6 — Medium(生产故障 · 契约缺口):`GET /counseling/services/intro` 后端不存在 - **后果**:两个小程序 intro 页 **100% 拿不到数据**。请求被 `/services/{id}` 吞掉后 以 `id="intro"` 查库 → 403/空,**属生产功能完全不可用**。 - **修复**:Controller 新增该端点,并**声明在 `/services/{id}` 之前** (Spring MVC 字面量路径必须优先于变量路径,否则被吞)。 ### D7 — Medium(契约缺口):Admin 调用 5 条不存在的运营端端点 - **位置**:`admin-web/src/modules/counseling/CounselingView.vue` - **后果**:5 条 `/counseling/services/operator/*` 请求全部 404; 且缺 Loading/Empty/Error/Permission 四态,权限不足时静默白屏。 - **修复**:后端补 8 个运营端端点(`@PreAuthorize` + `OperatorPrincipal`); 前端整体重写(补 `el-skeleton`/`el-empty`/`el-alert`/二次确认/防重复提交)。 ### D8 — Medium(契约缺口):小程序申请页字段名与后端不匹配 + 无幂等 + 静默失败 - **问题**:申请页提交 `userRequestSummary` / `preferredServiceMode`,后端只认 `summary` / `preferredMode`;无幂等键(连点产生重复申请); `my-counseling` 列表**未检查 `res.data.success`** → 403 时静默显示空列表; 跳转到**不存在**的 `counseling-detail` 页;WXML 内直接调用方法(非法用法)。 - **修复**:字段名对齐 + 补 `idempotencyKey` + 检查 `success` + 移除无效跳转 + 改为预计算字段(`statusName`/`createdText`/`scheduledText`)。 ### D9 — Medium(测试保真度):测试库 counseling 表零 CHECK 约束 - **问题**:`counseling_services` / `counseling_followups` 在 `schema-h2.sql` 中 **完全缺失 CHECK 约束**(V022 的 3 个 CHECK 未下沉); `counseling_services` 未被列入 `EntityMigrationParityTest` 任何集合(= 零覆盖), `counseling_followups` 仅在「可选白名单」(= 实际无人测)。 - **后果**:状态/类型越界在测试期不可见 → 测试全绿但生产 500。 - **修复**:`schema-h2.sql` 补 3 个 CHECK + `uk_counseling_idem` + 新建 `counseling_followups` 表;两表移入 `REQUIRED_IN_TEST_SCHEMA`。 --- ## 四、复验中发现的**测试基建缺陷**(守卫自身失效,非生产缺陷) Phase 13A 严格执行「守卫必须变红」纪律时,暴露了 3 处**守卫/测试自身**的缺陷。 它们不修就会产生「恒真假绿」,危害等同于生产缺陷。 ### T1 — `CounselingConstraintGuardTest` 5 个用例全红:守卫找不到 DDL - **现象**:首轮 5 个用例全部报「未解析到约束」。 - **根因**:`pom.xml` 的 surefire 配置了 `${project.basedir}/..` —— 测试进程 CWD 是**仓库根**而非 `backend/`。 更隐蔽的是 `backend/` 是 CWD 的**子目录**而非祖先, 所以「只向上回溯找 `src/...`」也找不到(实测报错 `CWD=/proj`)。 - **修复**:改为**候选后缀 + 逐级向上**双维度定位 (`src/main/resources/db/migration` 与 `backend/src/main/resources/db/migration`), 定位失败**大声抛异常**而不是静默返回 null。 - **加固**:新增文件数自检(`fileCount >= 30`), 避免「目录选错」与「DDL 真没有」无法区分。 ### T2 — `EntityMigrationParityTest` 全限定注解漏解析(假警报) - **现象**:报 `counseling_services: 生产缺列 [followups]`。 - **根因**:`@TableField(exist = false)` 写成了**全限定形式** `@com.baomidou.mybatisplus.annotation.TableField(exist = false)`, 而旧正则只匹配简单形式 `@TableField(...)` → 非持久化字段被误判为真实列。 - **危害**:若为消除该假警报而去生产库**加一个无用列**,就是测试保真度反噬成生产污染。 - **修复**:正则放宽为 `@[\w.]*TableField\(...\)`; 并新增回归用例 `fullyQualifiedTableFieldExistFalseMustBeExcluded`。 ### T3 — `OperatorEndpointPermissionCoverageTest` 扫到私有辅助方法(假阳性) - **现象**:报 `CounselingController.roleOf` 缺 Permission。 - **根因**:`roleOf(OperatorPrincipal)` 是**私有工具方法**(把 principal 映射成角色字符串), 不是 HTTP 端点,也不可能加 `@PreAuthorize`; 却因「参数含 `OperatorPrincipal`」被误判为未受保护的接口。 - **修复**:先确认方法是**真正的端点**(`findMergedAnnotation(RequestMapping)` 能取到路径)再判定; 并新增自检用例 `guardMustActuallyInspectOperatorEndpoints`(≥10 个端点)。 ### T4 — 两处测试自身逻辑缺陷(非生产缺陷) | 用例 | 误报原因 | 修复 | |---|---|---| | `matchmakerSeesSanitizedDetail` | 用「测试内存里的旧快照」(status 仍 `pending`)去比「重新查库的最新值」(`in_progress`) | 辅助方法改为返回**重新查库**的同源快照(`reload`) | | `companionConfirmIsIdempotent` | H2 timestamp 精度低于 `Instant.now()` 纳秒,导致 470ns 级差异误报 | 比较**截断到毫秒**的表现值 | --- ## 五、负向验证(守卫必须变红 —— 两轮,均已还原) ### 第一轮:DDL 常量守卫 注入缺陷:从 `V039` 的 `chk_counseling_status` 白名单中**移除** `scheduled`。 ``` [ERROR] CounselingConstraintGuardTest.statusMachineWithinProductionWhitelist:162 状态机使用了不在生产 CHECK 白名单内的状态值: - scheduled 生产白名单: [assigned, cancelled, closed, completed, in_progress, pending] [ERROR] Tests run: 48, Failures: 1, Errors: 0 → BUILD FAILURE ``` **关键结论**:守卫**正确解析了被篡改的 V039 原文**并识别出漂移 —— 证明它确实在解析**真实 DDL**,而不是在测试里抄了一份副本。 (这正是 Phase 13 确立的第四层纪律:抄副本的守卫对 DDL 漂移零反应。) ### 第二轮:行为守卫(3 个注入缺陷,3 个用例精准变红) | 注入缺陷 | 变红用例 | 失败信息(节选) | |---|---|---| | **A** `assign` 退回「先读后写」 | `concurrentAssignDoesNotSilentlyOverwrite:182` | `Expected IllegalStateException to be thrown, but nothing was thrown` | | **B** manager 也装配 followups | `managerSeesSummaryButNotFollowups:302` | `INV-013: 经理不得成为阅读跟进正文的绕过通道 ==> expected: but was: <[CounselingFollowup(...content=跟进正文内容(敏感)...)]>` | | **C** `assertTransition` 变空操作 | `completedServiceCannotBeCancelled:143` | `Expected IllegalStateException to be thrown, but nothing was thrown` | ``` [ERROR] Tests run: 48, Failures: 3, Errors: 0 → BUILD FAILURE ``` **结论**:三个行为不变量(并发安全 / INV-013 / 状态机)的守卫均**经负向验证证明会变红**, 不是"永不失效的假守卫"。两轮注入的缺陷**已全部还原**(`grep -c "NEG-VAL"` = 0)。 ### 额外发现(诚实记录) 第一轮同时移除了 `schema-h2.sql` 的 `chk_counseling_status` CHECK, 但**没有任何行为用例因此变红**。说明:**当前测试并未依赖测试库 CHECK 来兜底状态合法性** ——状态合法性是由 Service 层的 `assertTransition` 保证的。 这是一个值得记录的边界:测试库 CHECK 的价值在于「防止绕过 Service 层的直接 SQL 写入」, 而非替代 Service 层校验。已在报告中如实标注,未夸大其作用。 --- ## 六、全量回归实测 | 项 | 数值 | |---|---| | 后端全量测试 | **420 tests · 0 failures · 0 errors · 3 skipped** | | 构建结果 | `BUILD SUCCESS`(4 分 49 秒) | | Phase 13 基线 | 391 | | 本次净增 | **+29** | | `admin-web` `vue-tsc --noEmit` | EXIT=0 | | `miniapp` `tsc --noEmit` | EXIT=0 | 新增/重写测试文件: | 文件 | 用例数 | 说明 | |---|---|---| | `CounselingPhase13ATest` | 25 | Gate 闭环 + 计划项 2/3/4/5 全项 + INV-012/013/005 | | `CounselingConstraintGuardTest` | 5 | 解析真实 DDL 的常量守卫 + 解析器自检 | | `EntityMigrationParityTest`(+1) | 6 | 新增全限定注解回归守卫 | | `OperatorEndpointPermissionCoverageTest`(+1) | 2 | 新增端点覆盖自检 | 删除的测试(测死代码 / 名不副实):`CounselingDataScopeTest`。 --- ## 七、生产迁移 **新增 `V039__add_counseling_idempotency_and_status_guards.sql`**: 1. `ALTER TABLE counseling_services ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128)` 2. `CREATE UNIQUE INDEX IF NOT EXISTS uk_counseling_idem ON counseling_services(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL` 3. 按既有惯例(V018/V020/V025/V038)`DROP CONSTRAINT IF EXISTS` + 重加 `chk_request_source` / `chk_counseling_status` / `chk_counseling_service_type` 4. `COMMENT ON COLUMN` 说明 INV-005 与幂等语义 > `LATEST_MIGRATION` 38 → 39。 > H2 不支持 partial index,`schema-h2.sql` 用标准 `UNIQUE` 表达 > (PG/H2 的标准 UNIQUE 均允许多行 NULL 共存,语义等价)。 --- ## 八、结论 Phase 13A **CLOSED**。9 处缺陷(D1~D9)全部修复并验证; 4 处测试基建缺陷(T1~T4)一并修复; 两轮负向验证证明常量守卫与行为守卫**都会变红**; 全量回归 **420 tests 全绿**;前后端 type-check 均通过。 **Phase 14(多类型公益活动 — REFACTOR/EXTEND)未自动进入,等待用户确认。**