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
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
# 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)未自动进入,等待用户确认。**
|
||||
Reference in New Issue
Block a user