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
16 KiB
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:
ALTER TABLE counseling_services ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128)CREATE UNIQUE INDEX IF NOT EXISTS uk_counseling_idem ON counseling_services(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL- 按既有惯例(V018/V020/V025/V038)
DROP CONSTRAINT IF EXISTS+ 重加chk_request_source/chk_counseling_status/chk_counseling_service_type COMMENT ON COLUMN说明 INV-005 与幂等语义
LATEST_MIGRATION38 → 39。 H2 不支持 partial index,schema-h2.sql用标准UNIQUE表达 (PG/H2 的标准 UNIQUE 均允许多行 NULL 共存,语义等价)。
八、结论
Phase 13A CLOSED。9 处缺陷(D1D9)全部修复并验证;
4 处测试基建缺陷(T1T4)一并修复;
两轮负向验证证明常量守卫与行为守卫都会变红;
全量回归 420 tests 全绿;前后端 type-check 均通过。
Phase 14(多类型公益活动 — REFACTOR/EXTEND)未自动进入,等待用户确认。