Files
danyang-match/docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
T
WorkBuddy 7a2693b239 fix(counseling): close Phase 13A KEEP-VERIFY/EXTEND — 9 real defects + 4 test-infra defects
Phase 13A(婚恋咨询与婚前辅导)复验关闭。历史 ✅ 不可信:counseling 模块存在双实现,
线上权威 CounselingService(167 行)零测试,唯一被测的 CounselingServiceApp(280 行)
是孤儿类(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除。

生产缺陷(D1~D9):
- D1 Critical 生产故障 · INV-013 泄露:咨询正文 userRequestSummary 原文被写入 WARN 日志
  (同 Phase 10 D2 一族)→ 改为只记 serviceId + riskLevel,绝不记正文。
- D2 High 越权:申请 userId 取自请求体而非认证主体 → Controller 强制 principal.id()。
- D3 High 并发:分配用「先读后写」→ 两管理员并发分配静默覆盖;
  改 assignIfPending 条件更新(WHERE status='pending' AND assigned_operator_id IS NULL),
  affectedRows=0 明确报错。
- D4 High 越权读:counseling_manager 经 getDetail 可读 followups 正文 → INV-013 绕过通道;
  manager 只加回 userRequestSummary,不装配 followups。
- D5 Medium:无状态机 → 「已完成」可被再取消;新增 ALLOWED_TRANSITIONS + assertTransition。
- D6 Medium 生产故障:GET /counseling/services/intro 后端不存在(且被 /services/{id} 吞掉)
  → 小程序 intro 页 100% 无数据;补端点并声明在变量路径之前。
- D7 Medium 契约:Admin 调用 5 条不存在的 /services/operator/*(404)+ 缺四态
  → 后端补 8 个运营端端点(@PreAuthorize),前端整体重写 + 注册 /counseling 路由。
- D8 Medium 契约:小程序字段名错(userRequestSummary/preferredServiceMode)+ 无幂等
  + my-counseling 未检查 success(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法。
- D9 Medium 测试保真度:测试库 counseling 两表零 CHECK 约束,
  counseling_services 未列入 EntityMigrationParityTest 任何集合(= 零覆盖)。

测试基建缺陷(T1~T4,不修即恒真假绿):
- T1 守卫找不到 DDL:surefire workingDirectory = ${project.basedir}/.. 使 CWD 为仓库根,
  且 backend/ 是子目录非祖先 → 改为候选后缀 + 逐级向上定位,失败大声抛异常。
- T2 全限定 @com.baomidou...TableField(exist=false) 漏解析(假漂移警报)→ 正则放宽 + 回归守卫。
- T3 OperatorEndpointPermissionCoverageTest 扫到私有辅助方法(假阳性)→ 只扫描真实端点 + 自检。
- T4 两处测试自身逻辑缺陷:陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度。

迁移:新增 V039(counseling 幂等键 + 三个 CHECK 白名单显式固化),基线 38 → 39。

负向验证两轮(均已还原,grep -c NEG-VAL = 0):
- ① DDL 常量守卫:从 V039 移除 'scheduled' → 常量守卫精准变红
  (证明解析的是真实 DDL 而非副本)。
- ② 行为守卫:注入「并发回退先读后写 / manager 读 followups / 状态机守卫失效」
  → 三个用例各自精准变红。

测试:新增 CounselingPhase13ATest(25)+ CounselingConstraintGuardTest(5,解析真实 DDL);
删除孤儿类及其 3 个 DTO、删除测死代码的 CounselingDataScopeTest。
全量回归:Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS(4:49)。
基线 391 → 420(净 +29)。admin-web vue-tsc EXIT=0;miniapp tsc EXIT=0。

Tag: phase13a-recovery-2026-09-17
Report: docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
2026-09-17 01:25:12 +08:00

273 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 配置了
`<workingDirectory>${project.basedir}/..</workingDirectory>` ——
测试进程 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: <null> 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)未自动进入,等待用户确认。**