Files
danyang-match/docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
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

16 KiB
Raw Permalink Blame History

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 处缺陷(D1D9)全部修复并验证; 4 处测试基建缺陷(T1T4)一并修复; 两轮负向验证证明常量守卫与行为守卫都会变红; 全量回归 420 tests 全绿;前后端 type-check 均通过。

Phase 14(多类型公益活动 — REFACTOR/EXTEND)未自动进入,等待用户确认。