Files
danyang-match/docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md
T
WorkBuddy 4e478ce6ba fix(invariants): Phase 08 复验 —— 修复 INV-004 搜索/详情绕过 Block + matches.source 取值越界
复验(KEEP-VERIFY)在既有 Phase 08 实现中发现 3 处真实缺陷并修复:

D1(严重)DiscoverService.searchUsers 不含查看者身份,查询条件既无排除自己也无任何 Block 条件,被拉黑者仍可被搜到并点开 —— INV-004「搜索不可见」整体失效。改为接收 viewerUserId,排除自己,并用新增的 UserRelationMapper.findBlockedUserIdsBothWays(UNION 双向)一次取回后 notIn 批量排除,避免 N+1。

D2 详情路径(DiscoverService / UserProfileService)均无 Block 判断,知道 userId 即可读到被拉黑者资料。两条路径统一加双向判断并返回 null,由 Controller 转 404(不用「已隐藏」以免被用来探测存在);UserProfileService 中该判断排在隐私设置之前,拉黑不能被对方「允许搜索可见」覆盖。

D3(潜伏,仅 PostgreSQL 暴露)RelationshipService.like() 把关系类型值 "liked" 当 match source 写入 matches.source,违反 V003:91 的 chk_match_source。测试库 schema-h2.sql 该列是裸 VARCHAR(32)、无约束,故长期静默通过。改传 MATCH_SOURCE_USER_LIKE。canonical 路径 RelationService.like 传值本就正确,线上未触发。

测试保真度对齐:schema-h2.sql 补 chk_match_source(与 V003 逐字对齐)与 user_profiles 表(此前测试库无此表,资料查询无法端到端测试)。

新增 DiscoverBlockInvisibilityIntegrationTest(9 用例,@SpringBootTest 真实 H2 + 真实 SQL),覆盖正向/反向拉黑、对称性、排除自己、详情 404 与查自己不受限。P0FixVerificationTest 补 source 回归守卫。DiscoverServiceTest 同步构造签名。

负向验证:临时还原 D3 修复后,4 个用例因 CHK_MATCH_SOURCE 约束违反报错,证明守卫有效。

证据:后端全量 308 tests / 0 failures / 0 errors / 3 skipped(基线 299);UI 契约 13/13 GREEN。报告 docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md。

同步 STATUS.md / JIRA_BACKLOG.md(EPIC-P08-V11 置 ✅)/ 客户需求基线计划 Phase 08 复选框;首轮报告加状态注记(其「Known Issues 无」已被推翻)。.gitignore 忽略 .workbuddy/。
2026-09-16 15:11:38 +08:00

13 KiB
Raw Blame History

Phase 08 V1.1 Recovery Completion Report

阶段:Like / Match / Block — KEEP-VERIFY/EXTEND 分支:main 日期:2026-09-16 结论:Phase 08 复验通过并关闭;期间发现并修复 3 处真实缺陷(其中 1 处为只在 PostgreSQL 上暴露的潜伏缺陷) 未进入 Phase 09 —— 按计划每阶段输出报告后停止,等待用户指令


1. 阶段目标(来自客户需求基线增量开发与修复计划)

  • 验证 Like、Pass、普通 Mutual、Unmatch、Block 全链路。
  • 增加 Activity Match Episode 兼容,不改变普通 Match 的双向 Like 规则。
  • 验证 Block 后推荐排除、搜索不可见、聊天停止且无法由缓存绕过。

Gate: V1.0 核心回归通过;活动来源兼容不会创建第二套 Match 逻辑。


2. 复验方式

本轮不是"重写功能",而是对既有实现做不变量复验(KEEP-VERIFY)。方法是逐条把 INV-004(Block 优先级最高)的三条下游语义 (推荐排除 / 搜索不可见 / 聊天停止)落到代码路径上核对,而不是采信历史报告里的 ✅。

2.1 复验结论矩阵

INV-004 下游语义 承载代码 复验前 处置
推荐排除(双向) RecommendationEngineService.queryCandidatePool 尾部 isStillEligible 二次过滤 ✅ 已双向 无需改动
搜索不可见(双向) DiscoverService.searchUsers ❌ 完全失效 已修复(缺陷 D1)
详情不可见(双向) DiscoverService.getPublicProfile / UserProfileService.getPublicProfile ❌ 无 Block 检查 已修复(缺陷 D2)
聊天停止(双向) ChatService.sendText + 会话 DTO 的 relationStatus ✅ 已双向 无需改动
Match 创建受 Block 拦截 MatchService.createOrReuseMatch / MatchEligibilityService / RelationService.canInteract ✅ 无需改动
Activity Match Episode 兼容 RelationshipService.confirmActivityInterest(source=activity_mutual,autoCreateConversation=false) ✅ 首轮已交付 本轮仅复验,未改

2.2 为什么"推荐排除"复验为通过(易误判点)

queryCandidatePool 中确实只有单向排除:

List<String> blockedIds = relationMapper.findBlockedByUser(userId)  // 只有"我拉黑的人"

但该方法尾部还有一次对每个候选的实时复核:

// 最终实时资格过滤包含双向 Block、关系状态、推荐开关、隐私和可选本地资格门禁。
candidates = candidates.stream()
        .filter(p -> isStillEligible(userId, p.getUserId()))
        .collect(Collectors.toList());

而 isStillEligible 同时检查 userId→target 与 target→userId 两个方向的 blocked 行。 缓存命中路径也走同一个 isStillEligible,因此**"无法由缓存绕过"成立**。 → 单向过滤只是提前剪枝的优化,不构成语义缺口。只看前半段就会误判为缺陷。


3. 本轮发现并修复的缺陷

缺陷 D1(严重)— 搜索完全绕过 Block:INV-004「搜索不可见」失效

项 内容
位置 platform/discover/service/DiscoverService.searchUsers
症状 方法签名不含查看者身份,查询条件里既无"排除自己",也无任何 Block 条件。被拉黑的人仍可在搜索列表中出现并可被点开
影响 INV-004 三条下游语义中的一条整体失效;拉黑是用户唯一的自我保护手段
修复 签名改为 searchUsers(String viewerUserId, String myGender, ...);ne(userId, viewerUserId) 排除自己;一次 UNION 查询取回双向拉黑 id 集合后 notIn 批量排除
调用方同步 DiscoverController.search 传入 userId(原方法内已有该变量,只是没往下传)
为何用批量 notIn 若逐条 countBlockedEitherWay 会产生 N+1;UNION 一次拿全,语义等价且可走索引

新增 Mapper 方法(UserRelationMapper):

@Select("SELECT target_user_id FROM user_relations WHERE user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL "
        + "UNION "
        + "SELECT user_id FROM user_relations WHERE target_user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL")
List<String> findBlockedUserIdsBothWays(@Param("userId") String userId);

注意:原有 findBlockedByUser 是单向的(我拉黑的人),不足以支撑 INV-004 的对称语义,故新增上述方法而非复用。

缺陷 D2 — 详情页对拉黑者仍可见

项 内容
位置 DiscoverService.getPublicProfile、UserProfileService.getPublicProfile
症状 两条详情路径都没有 Block 判断。即使列表已过滤,只要知道 userId 就能直接读到被拉黑者资料
修复 两条路径统一加双向判断,命中即返回 null,由 Controller 转 404
语义选择 用 404 而非"已隐藏":不返回可被用来探测"该用户存在但屏蔽了我"的差异化响应
优先级选择 UserProfileService 中该判断排在隐私设置之前 —— 拉黑是显式阻断,不能被对方"允许搜索可见"的设置覆盖

缺陷 D3(潜伏,只在 PostgreSQL 暴露)— Match source 取值越界

项 内容
位置 platform/relationship/service/RelationshipService.like()
症状 互选成立后调用 createMatch(userId, targetUserId, Constants.RELATION_TYPE_LIKED),把关系类型值 "liked" 当作 match source 写入 matches.source
生产后果 迁移 V003__add_review_and_relation_tables.sql:91 定义了 CHECK (source IN ('user_like','activity_mutual','matchmaker')) → PostgreSQL 上第二条 Like 必然抛约束违反 → 500
为何长期未暴露 测试库 schema-h2.sql 里 matches.source 只是裸 VARCHAR(32),没有该 CHECK 约束 → 非法值静默放过,测试全绿
修复 改传 Constants.MATCH_SOURCE_USER_LIKE("user_like")
是否影响线上 RelationshipController 的 like 路由已于 2026-09-14 移除,canonical 路径走 RelationService.like(传值正确),故线上未实际触发;但该方法是 P0FixVerificationTest 的 Match 构造入口,且属真实越界取值,必须修

判断依据:canonical 路径 RelationService.like → MatchService.createOrReuseMatch(..., Constants.MATCH_SOURCE_USER_LIKE, null) 用的是正确值, 说明 RelationshipService.like 属 V1.0 遗留路径,其 source 传值是遗留笔误。


4. 测试保真度改进(本轮最重要的方法论产出)

D3 能潜伏至今,根因不在业务代码,而在测试库不承载生产约束:H2 schema 允许了 PostgreSQL 禁止的取值。 本轮把生产约束下沉到测试库,使这类缺陷不可能再次静默通过。

改动 文件 说明
补 chk_match_source backend/src/test/resources/schema-h2.sql 与 V003:91 逐字对齐,注释标明来源迁移
补 user_profiles 表 同上 与 V002 列名/语义一致;此前测试库根本没有该表,导致任何涉及资料查询的路径都无法端到端测试

负向验证(证明守卫真的会红,而不是永远为真)

按项目既定纪律「一条永远为真的断言等于没有断言」,把 D3 的修复临时还原后重跑:

[ERROR] Tests run: 8, Failures: 0, Errors: 4
### Error updating database. Cause: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException:
    Check constraint violation: "CHK_MATCH_SOURCE: "; SQL statement:

→ 4 个用例立即因约束违反报错,随后还原修复。守卫有效性已证实。


5. 新增测试

5.1 DiscoverBlockInvisibilityIntegrationTest(新增,9 用例)

真实集成测试(@SpringBootTest + @ActiveProfiles("test")),跑真实 H2 与真实 MyBatis SQL, 验证 D1/D2 的修复确实生效,而非仅断言常量字符串。

用例 断言
无拉黑关系时双方均可见 基线,防止过滤逻辑误伤
我拉黑的人不出现在我的搜索里 D1 正向
拉黑我的人不出现在我的搜索里 D1 反向(单向实现必然漏掉这一条)
拉黑后两侧互搜不到(对称) D1 对称性
搜索不包含查看者自己 独立分支
无拉黑关系时详情可见 D2 基线
我拉黑对方后详情返回 null D2 正向
对方拉黑我后详情返回 null D2 反向
查看自己的资料不受 Block 限制 边界(避免误伤本人)

5.2 P0FixVerificationTest(增强,非新增)

在既有 testMatchAutoCreatesConversation 中补一条回归守卫:

// matches.source 必须落在 V003 chk_match_source 允许的取值域内。
// 回归守卫:曾误传 RELATION_TYPE_LIKED("liked"),H2 无该约束时静默通过、PostgreSQL 上违反约束。
assertEquals(Constants.MATCH_SOURCE_USER_LIKE, match.getSource(),
        "双向 Like 产生的 Match,source 应为 user_like");

5.3 DiscoverServiceTest(同步)

构造签名变更同步:new DiscoverService(null) → new DiscoverService(null, null)。


6. 验证证据

项目 结果
Backend 全量回归(离线容器 danyang-match-backend-test,mvn -o test) 308 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
其中新增 +9(DiscoverBlockInvisibilityIntegrationTest)
基线对比 299 → 308
定向复跑(D1/D2/D3 相关) 17 tests / 0 failures / 0 errors
负向验证 还原 D3 修复 → 4 errors(CHK_MATCH_SOURCE 约束违反)✔ 守卫有效
UI 契约门禁 13/13 GREEN
git diff --check 通过(无空白错误)

Activity Match Episode 兼容复验:confirmActivityInterest 仍只创建 source=activity_mutual 的单一 Match Episode, autoCreateConversation=false,未新增第二套 Match 逻辑 → Gate「活动来源兼容不会创建第二套 Match 逻辑」成立。


7. 已知限制

  • verified 字段仍硬编码 true:DiscoverService / UserProfileService 的公开资料响应中 verified 恒为 true, 与真实实名状态无关。属 Phase 03/04 遗留(与 /me/status 曾写死 verified=false 同源问题), 不在 Phase 08 范围内,本轮未动,登记为后续待办。
  • RelationshipService 与 RelationService 两套 Like/Block 实现并存:canonical 为 RelationService(09-14), RelationshipService 的写方法控制器路由已移除、但服务方法仍被 2 个测试直接调用,故保留。 彻底去重涉及测试改造,建议在后续阶段单独立项。
  • 推荐排除的"双向"语义由 isStillEligible 保障;若将来有人重写 queryCandidatePool 而丢掉尾部这次过滤, 会出现回归。当前无自动化断言锁定该点,建议后续补一条(本轮未做,避免超出 Phase 08 边界)。

8. Gate 判定

Gate 条款 判定 依据
V1.0 核心回归通过 ✅ 308 tests / 0 failures / 0 errors
活动来源兼容不会创建第二套 Match 逻辑 ✅ confirmActivityInterest 复用同一 createMatch,仅参数不同
Like、Pass、Mutual、Unmatch、Block 全链路已验证 ✅ 见 §2.1 矩阵 + §5.1 集成测试
Block 后推荐排除 / 搜索不可见 / 聊天停止 且不可由缓存绕过 ✅ 推荐/搜索/聊天三条路径均双向;缓存路径同样过 isStillEligible

Phase 08:通过并关闭。


9. 变更清单

主代码

  • backend/.../match/mapper/UserRelationMapper.java — 新增 findBlockedUserIdsBothWays
  • backend/.../discover/service/DiscoverService.java — 加 viewerUserId;排除自己与双向拉黑;详情加 Block 判断
  • backend/.../discover/controller/DiscoverController.java — 传入 principal.id()
  • backend/.../profile/service/UserProfileService.java — 注入 UserRelationMapper;详情加 Block 判断(排在隐私设置前)
  • backend/.../relationship/service/RelationshipService.java — 修 D3:match source 改用 MATCH_SOURCE_USER_LIKE

测试

  • backend/.../discover/DiscoverBlockInvisibilityIntegrationTest.java — 新增 9 用例
  • backend/.../invariants/P0FixVerificationTest.java — 新增 source 回归守卫
  • backend/.../discover/DiscoverServiceTest.java — 同步构造签名
  • backend/src/test/resources/schema-h2.sql — 补 chk_match_source + user_profiles

工程

  • .gitignore — 忽略 .workbuddy/(与既有 .zcode/ 一致)

10. 下一阶段

不自动进入 Phase 09。 按计划要求,每阶段输出报告后停止,等待用户确认。

Phase 09(Text Chat — KEEP-VERIFY/EXTEND)待办预告:

  • 验证 REST send + WebSocket receive、消息幂等、重连与未读计数
  • 验证非 active Match 禁聊,Unmatch / Block 后即时禁聊
  • 让活动双确认产生的 Conversation 复用同一消息体系