# 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. 阶段目标(来自客户需求基线增量开发与修复计划) - [x] 验证 Like、Pass、普通 Mutual、Unmatch、Block 全链路。 - [x] 增加 Activity Match Episode 兼容,不改变普通 Match 的双向 Like 规则。 - [x] 验证 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` 中确实只有**单向**排除: ```java List blockedIds = relationMapper.findBlockedByUser(userId) // 只有"我拉黑的人" ``` 但该方法**尾部**还有一次对每个候选的实时复核: ```java // 最终实时资格过滤包含双向 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`): ```java @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 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` 中补一条回归守卫: ```java // 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 复用同一消息体系