复验(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/。
13 KiB
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— 新增findBlockedUserIdsBothWaysbackend/.../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 复用同一消息体系