diff --git a/.gitignore b/.gitignore index 106be99..d380092 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ tests/fixtures/*.json # Local workspace and developer settings .worktrees/ .zcode/ +.workbuddy/ project.private.config.json /nul /infrastructure/nul diff --git a/backend/src/main/java/com/danyang/match/platform/discover/controller/DiscoverController.java b/backend/src/main/java/com/danyang/match/platform/discover/controller/DiscoverController.java index 533e2e8..8dc5d1c 100644 --- a/backend/src/main/java/com/danyang/match/platform/discover/controller/DiscoverController.java +++ b/backend/src/main/java/com/danyang/match/platform/discover/controller/DiscoverController.java @@ -48,18 +48,20 @@ public class DiscoverController { if (maritalStatus != null) tempFilter.put("maritalStatus", maritalStatus); if (educationCode != null) tempFilter.put("educationCode", educationCode); - return ApiResponse.ok(discoverService.searchUsers(myGender, tempFilter, page, pageSize)); + return ApiResponse.ok(discoverService.searchUsers(userId, myGender, tempFilter, page, pageSize)); } /** * 查看用户详情 * GET /api/v1/discover/users/{userId} + * + * INV-004:双向拉黑时 service 返回 null,这里按 404 处理(不泄露"存在但不可见")。 */ @GetMapping("/users/{userId}") public ApiResponse> getProfile( @AuthenticationPrincipal UserPrincipal principal, @PathVariable String userId) { - Map profile = discoverService.getPublicProfile(userId); + Map profile = discoverService.getPublicProfile(principal.id(), userId); if (profile == null) { return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在"); } diff --git a/backend/src/main/java/com/danyang/match/platform/discover/service/DiscoverService.java b/backend/src/main/java/com/danyang/match/platform/discover/service/DiscoverService.java index 7967063..deaa010 100644 --- a/backend/src/main/java/com/danyang/match/platform/discover/service/DiscoverService.java +++ b/backend/src/main/java/com/danyang/match/platform/discover/service/DiscoverService.java @@ -1,7 +1,7 @@ package com.danyang.match.platform.discover.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.danyang.match.platform.match.dto.RecommendationCandidateDTO; +import com.danyang.match.platform.match.mapper.UserRelationMapper; import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.shared.constant.Constants; @@ -14,29 +14,45 @@ import java.util.stream.Collectors; /** * 找对象搜索服务 * 支持临时筛选(不修改用户择偶条件) + * + * INV-004(Block 优先级最高)在本模块的两条检索路径上都必须生效: + * 列表搜索排除双向拉黑者,详情查看对双向拉黑者返回不存在。 */ @Service @RequiredArgsConstructor public class DiscoverService { private final UserProfileMapper userProfileMapper; + private final UserRelationMapper relationMapper; /** * 搜索用户列表(临时筛选) * + * @param viewerUserId 当前登录用户(认证主体)—— 用于排除自己与双向拉黑者 * @param myGender 当前用户性别(用于过滤相反性别) * @param tempFilter 临时筛选条件(不影响用户择偶条件) * @param page 页码 * @param pageSize 每页数量 * @return 搜索结果 */ - public Map searchUsers(String myGender, Map tempFilter, + public Map searchUsers(String viewerUserId, String myGender, Map tempFilter, int page, int pageSize) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.ne(UserProfile::getGender, myGender) .eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED) .isNull(UserProfile::getDeletedAt); + // INV-004 搜索不可见:排除自己 + 双向拉黑者。 + // 用一次 UNION 查询取回全部拉黑 id 后批量 NOT IN,避免逐条 countBlockedEitherWay 造成 N+1。 + // viewerUserId 为 null 只出现在非法/未认证调用路径上,此时不做排除(上层鉴权已拒绝)。 + if (viewerUserId != null) { + wrapper.ne(UserProfile::getUserId, viewerUserId); + List blockedUserIds = relationMapper.findBlockedUserIdsBothWays(viewerUserId); + if (blockedUserIds != null && !blockedUserIds.isEmpty()) { + wrapper.notIn(UserProfile::getUserId, blockedUserIds); + } + } + // 应用临时筛选条件 if (tempFilter.containsKey("minAge")) { Integer minAge = (Integer) tempFilter.get("minAge"); @@ -88,8 +104,17 @@ public class DiscoverService { /** * 获取单个用户公开详情 + * + * @param viewerUserId 查看者(认证主体) + * @param userId 被查看者 + * @return 双向拉黑时返回 null,上层据此返回 404 —— 不返回"已隐藏"这类可被用来探测存在的响应 */ - public Map getPublicProfile(String userId) { + public Map getPublicProfile(String viewerUserId, String userId) { + // INV-004 搜索不可见:任一方向拉黑后互相不可见;查看自己的资料不受此限制 + if (viewerUserId != null && !viewerUserId.equals(userId) + && relationMapper.countBlockedEitherWay(viewerUserId, userId) > 0) { + return null; + } return userProfileMapper.findByUserId(userId) .map(profile -> { Map map = new HashMap<>(); diff --git a/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java b/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java index 5993bcc..60cb6b2 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java @@ -50,6 +50,17 @@ public interface UserRelationMapper extends BaseMapper { @Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'passed' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100") List findPassedByUser(String userId); + /** + * 与我有拉黑关系的全部用户 id(双向 UNION 去重)—— 供列表/搜索批量排除。 + * + * INV-004:Block 优先级最高,拉黑后「搜索不可见」是双向的: + * 我拉黑的人看不到我,拉黑我的人也看不到我。单方向的 findBlockedByUser 不足以支撑该语义。 + */ + @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); + /** 插入关系(依赖唯一约束去重,返回 0 表示已存在) */ @org.apache.ibatis.annotations.Insert("INSERT INTO user_relations (id, user_id, target_user_id, relation_type) VALUES (#{id}, #{userId}, #{targetUserId}, #{relationType})") int insertIgnore(UserRelation relation); diff --git a/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java b/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java index f76f937..bbadef5 100644 --- a/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java +++ b/backend/src/main/java/com/danyang/match/platform/profile/service/UserProfileService.java @@ -8,6 +8,7 @@ import com.danyang.match.platform.profile.entity.UserPrivacySettings; import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; +import com.danyang.match.platform.match.mapper.UserRelationMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.shared.constant.Constants; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class UserProfileService { private final UserProfileMapper userProfileMapper; private final UserPreferenceMapper userPreferenceMapper; private final UserPrivacySettingsMapper privacySettingsMapper; + private final UserRelationMapper relationMapper; /** * 获取自己完整资料(含隐私设置) @@ -141,6 +143,13 @@ public class UserProfileService { UserProfile profile = userProfileMapper.findByUserId(targetUserId).orElse(null); if (profile == null) return null; + // INV-004:Block 优先级最高 —— 任一方向拉黑后互相不可见。 + // 必须排在隐私设置之前:拉黑是用户的显式阻断,不能被对方"允许搜索可见"的设置覆盖。 + if (viewerId != null && !viewerId.equals(targetUserId) + && relationMapper.countBlockedEitherWay(viewerId, targetUserId) > 0) { + return null; + } + // 隐私检查:若对方关闭了搜索可见性,则不展示 UserPrivacySettings privacy = privacySettingsMapper.findByUserId(targetUserId).orElse(null); if (privacy != null && !privacy.getVisibleInSearch()) { diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java index cef45f9..ea57b88 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java @@ -76,7 +76,10 @@ public class RelationshipService { result.put("mutual", mutual); if (mutual) { - createMatch(userId, targetUserId, Constants.RELATION_TYPE_LIKED); + // matches.source 的取值域由 V003 的 chk_match_source 约束限定为 + // user_like / activity_mutual / matchmaker。此处必须传 MATCH_SOURCE_USER_LIKE; + // 早期误传 RELATION_TYPE_LIKED("liked") 在 H2(测试库无该约束)能过,在 PostgreSQL 上必违反约束。 + createMatch(userId, targetUserId, Constants.MATCH_SOURCE_USER_LIKE); result.put("matchCreated", true); log.info("双向Match创建: userId={}, targetUserId={}", userId, targetUserId); } diff --git a/backend/src/test/java/com/danyang/match/platform/discover/DiscoverBlockInvisibilityIntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/discover/DiscoverBlockInvisibilityIntegrationTest.java new file mode 100644 index 0000000..1e9f6a8 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/discover/DiscoverBlockInvisibilityIntegrationTest.java @@ -0,0 +1,200 @@ +package com.danyang.match.platform.discover; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.discover.service.DiscoverService; +import com.danyang.match.platform.match.entity.UserRelation; +import com.danyang.match.platform.match.mapper.UserRelationMapper; +import com.danyang.match.platform.match.service.RelationService; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * INV-004(Block 优先级最高)在「搜索/详情」两条检索路径上的端到端验证。 + * + * 与 {@link DiscoverServiceTest} 的区别:本类跑真实 H2 库 + 真实 MyBatis SQL, + * 验证 UNION 双向拉黑 SQL、NOT IN 批量排除、以及 404 语义确实生效, + * 而不是只断言常量字符串。 + * + * 历史缺陷(本轮修复):searchUsers 原先不接收查看者身份, + * 导致「我拉黑的人」与「拉黑我的人」都能在搜索结果里被检索到 —— INV-004 的 + * "搜索不可见" 完全失效;getPublicProfile 同样无 Block 检查。 + */ +@SpringBootTest +@ActiveProfiles("test") +class DiscoverBlockInvisibilityIntegrationTest { + + @Autowired private DiscoverService discoverService; + @Autowired private RelationService relationService; + @Autowired private UserMapper userMapper; + @Autowired private UserProfileMapper userProfileMapper; + @Autowired private UserRelationMapper relationMapper; + + private String viewer; // male + private String other; // female + private String third; // female + + @BeforeEach + void setUp() { + viewer = createUserWithProfile("viewer", "male"); + other = createUserWithProfile("other", "female"); + third = createUserWithProfile("third", "female"); + } + + @AfterEach + void tearDown() { + // 关系按「涉及任一测试用户」清理(双向拉黑会产生反向行) + List relations = relationMapper.selectList( + new LambdaQueryWrapper() + .in(UserRelation::getUserId, List.of(viewer, other, third)) + .or().in(UserRelation::getTargetUserId, List.of(viewer, other, third))); + relations.forEach(r -> relationMapper.deleteById(r.getId())); + + userProfileMapper.deleteById(viewer); + userProfileMapper.deleteById(other); + userProfileMapper.deleteById(third); + userMapper.deleteById(viewer); + userMapper.deleteById(other); + userMapper.deleteById(third); + } + + // ==================== 搜索路径 ==================== + + @Test + @DisplayName("INV-004 基线:无拉黑关系时,双方均可见") + void searchShowsBothWhenNoBlock() { + List ids = searchIds(viewer); + + assertTrue(ids.contains(other), "无拉黑关系时 other 应可见"); + assertTrue(ids.contains(third), "无拉黑关系时 third 应可见"); + } + + @Test + @DisplayName("INV-004 搜索不可见(正向):我拉黑的人不再出现在我的搜索里") + void searchHidesUsersIBlocked() { + relationService.block(viewer, other); + + List ids = searchIds(viewer); + + assertFalse(ids.contains(other), "被我拉黑的人不应出现在我的搜索结果中"); + assertTrue(ids.contains(third), "无关用户 third 仍应可见(排除逻辑不得误伤)"); + } + + @Test + @DisplayName("INV-004 搜索不可见(反向):拉黑我的人不再出现在我的搜索里") + void searchHidesUsersWhoBlockedMe() { + // 关键用例:单向 findBlockedByUser 无法覆盖此场景 + relationService.block(other, viewer); + + List ids = searchIds(viewer); + + assertFalse(ids.contains(other), "拉黑我的人不应出现在我的搜索结果中"); + assertTrue(ids.contains(third), "无关用户 third 仍应可见"); + } + + @Test + @DisplayName("INV-004 搜索不可见:拉黑者自己也搜不到被拉黑者(对称)") + void searchIsSymmetricAfterBlock() { + relationService.block(viewer, other); + + List viewerSide = searchIds(viewer); + List otherSide = searchIds(other); + + assertFalse(viewerSide.contains(other), "viewer 搜不到 other"); + assertFalse(otherSide.contains(viewer), "other 也搜不到 viewer"); + } + + @Test + @DisplayName("搜索排除自己:结果里不应包含查看者本人") + void searchExcludesSelf() { + // third 也是 female,这里让 viewer 搜 female,自己(male)本就被性别过滤挡住; + // 因此再用 third 作为查看者验证「排除自己」这一独立分支。 + List ids = searchIds(third); + + assertFalse(ids.contains(third), "搜索结果不应包含查看者自己"); + } + + // ==================== 详情路径 ==================== + + @Test + @DisplayName("INV-004 详情:无拉黑关系时可正常查看") + void profileVisibleWithoutBlock() { + Map profile = discoverService.getPublicProfile(viewer, other); + + assertNotNull(profile, "无拉黑关系时详情应可见"); + assertEquals(other, profile.get("userId")); + } + + @Test + @DisplayName("INV-004 详情:我拉黑对方后返回 null(上层转 404)") + void profileHiddenWhenIBlocked() { + relationService.block(viewer, other); + + assertNull(discoverService.getPublicProfile(viewer, other), + "被我拉黑者的详情应不可见(返回 null 由上层转 404)"); + } + + @Test + @DisplayName("INV-004 详情:对方拉黑我后返回 null(反向)") + void profileHiddenWhenBlockedMe() { + relationService.block(other, viewer); + + assertNull(discoverService.getPublicProfile(viewer, other), + "拉黑我者的详情应不可见"); + } + + @Test + @DisplayName("详情:查看自己的资料不受 Block 限制") + void profileOfSelfAlwaysVisible() { + assertNotNull(discoverService.getPublicProfile(viewer, viewer), + "查看自己的资料不应被 Block 规则拦截"); + } + + // ==================== 辅助 ==================== + + @SuppressWarnings("unchecked") + private List searchIds(String viewerUserId) { + String myGender = userProfileMapper.findByUserId(viewerUserId) + .map(UserProfile::getGender).orElse(null); + Map result = discoverService.searchUsers( + viewerUserId, myGender, new HashMap<>(), 1, 50); + + return ((List>) result.get("records")) + .stream().map(m -> (String) m.get("userId")).toList(); + } + + private String createUserWithProfile(String name, String gender) { + User u = new User(); + u.setWechatOpenid("discover_test_" + name + "_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING); + u.setRecommendationEnabled(true); + userMapper.insert(u); + + UserProfile p = new UserProfile(); + p.setUserId(u.getId()); + p.setDisplayName("用户" + name); + p.setGender(gender); + p.setBirthDate(LocalDate.of(1993, 1, 1)); + p.setRegionCode("320681"); + p.setRegionName("江苏省镇江市丹阳市"); + p.setProfileCompletion(100); + p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED); + userProfileMapper.insert(p); + + return u.getId(); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/discover/DiscoverServiceTest.java b/backend/src/test/java/com/danyang/match/platform/discover/DiscoverServiceTest.java index 30fbb48..703789b 100644 --- a/backend/src/test/java/com/danyang/match/platform/discover/DiscoverServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/discover/DiscoverServiceTest.java @@ -19,7 +19,7 @@ class DiscoverServiceTest { @Test void testAgeNull() { - DiscoverService service = new DiscoverService(null); + DiscoverService service = new DiscoverService(null, null); Integer age = ReflectionTestUtils.invokeMethod(service, "calculateAge", (java.time.LocalDate) null); assertEquals(0, age); } diff --git a/backend/src/test/java/com/danyang/match/platform/invariants/P0FixVerificationTest.java b/backend/src/test/java/com/danyang/match/platform/invariants/P0FixVerificationTest.java index e5023cb..8061fcd 100644 --- a/backend/src/test/java/com/danyang/match/platform/invariants/P0FixVerificationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/invariants/P0FixVerificationTest.java @@ -204,6 +204,10 @@ class P0FixVerificationTest { // 2. 验证Match存在 Match match = matchMapper.findActiveMatch(userA, userB).orElse(null); assertNotNull(match, "Match应已创建"); + // 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"); // 3. 验证Conversation已自动创建 var convs = conversationMapper.selectList( diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index a3a8a33..6637971 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -12,11 +12,26 @@ CREATE TABLE user_relations ( created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, UNIQUE (user_id, target_user_id, relation_type) ); +-- 与 V002__add_user_and_verification_tables.sql 的 user_profiles 对齐(列名/语义一致), +-- 供 DiscoverService 搜索(INV-004 搜索不可见)等需要真实资料的用例使用。 +CREATE TABLE user_profiles ( + user_id VARCHAR(36) PRIMARY KEY, display_name VARCHAR(64), gender VARCHAR(16), + birth_date DATE, height_cm INT, region_code VARCHAR(32), region_name VARCHAR(128), + marital_status VARCHAR(32), children_status VARCHAR(32), education_code VARCHAR(32), + occupation_code VARCHAR(32), work_region_code VARCHAR(32), smoking_status VARCHAR(32), + drinking_status VARCHAR(32), long_term_danyang VARCHAR(32), future_danyang VARCHAR(32), + relationship_goal VARCHAR(64), marriage_plan VARCHAR(32), self_intro CLOB, + profile_completion INT DEFAULT 0, profile_status VARCHAR(32) DEFAULT 'in_progress', + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE +); CREATE TABLE matches ( id VARCHAR(36) PRIMARY KEY, user_a_id VARCHAR(36), user_b_id VARCHAR(36), status VARCHAR(32), source VARCHAR(32), source_id VARCHAR(64), last_activity_at TIMESTAMP WITH TIME ZONE, unmatched_by VARCHAR(36), episode INT, matched_at TIMESTAMP WITH TIME ZONE, unmatched_at TIMESTAMP WITH TIME ZONE, successful_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, + -- 与 V003__add_review_and_relation_tables.sql:91 的生产约束保持一致。 + -- 测试库若缺此约束,形如 source='liked' 的非法取值会被静默放过,只在 PostgreSQL 上炸。 + CONSTRAINT chk_match_source CHECK (source IN ('user_like', 'activity_mutual', 'matchmaker')) ); CREATE TABLE relationship_success_requests ( id VARCHAR(36) PRIMARY KEY, match_id VARCHAR(36), initiated_by VARCHAR(36), status VARCHAR(32), confirmed_by VARCHAR(36), diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index d6167b2..7ebc33b 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -5,6 +5,10 @@ > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > +> **2026-09-16 Phase 08 复验关闭:** EPIC-P08-V11 已完成。复验中发现并修复 3 处真实缺陷(搜索绕过 Block / 详情无 Block 检查 / `matches.source` 取值越界), +> 新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例)并把生产 `chk_match_source` 约束下沉到测试库。后端全量 **308 tests / 0 failures / 0 errors**。 +> 证据:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 09 需用户确认后启动。** +> > **2026-09-13 状态校正(历史):** 本文件中的既有 `✅` 是历史开发声明,不再单独作为验收证据。Phase 01~06 已由恢复版 Completion Report 重新举证;Phase 07 仅保留业务测试证据,管理端与小程序视觉已被用户否决并重新打开。Phase 08 暂停,当前先执行 UI 清理与参考图重设计。 | 当前执行项 | 状态 | 依据 | @@ -16,6 +20,7 @@ | Phase 05 统一审核扩展 | 已完成 | 见 `Phase-05-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 06 尚未开始 | | Phase 06 Match Eligibility 增量 | 已完成 | 见 `Phase-06-V11-Recovery-Completion-Report-2026-09-12.md`;Phase 07 尚未开始 | | Phase 07 V1.1 首页唯一实现 + 找对象 | 已完成:视觉已通过用户验收(2026-09-16) | 保留首页聚合、Capability、8 入口和推荐规则证据;UI 已按 2026-09-13 新规格重新实现,11 页经用户验收通过 | +| Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿;Phase 09 尚未获准 | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | @@ -44,7 +49,7 @@ | EPIC-P05-V11 | P05 | 本地资格审核复用 | P1 | | EPIC-P06-V11 | P06 | Match Eligibility扩展 | P0 | | EPIC-P07-V11 | P07 | 首页V1.1重构与找对象 | P0 | -| EPIC-P08-V11 | P08 | Like/Match保留+活动兼容 | P0 | +| EPIC-P08-V11 | P08 | Like/Match保留+活动兼容 | P0 | ✅ | EPIC-P09-V11 | P09 | 聊天核心保持+活动兼容 | P0 | ✅ | EPIC-P10-V11 | P10 | 聊天安全扩展 | P0 | ✅ | EPIC-P11-V11 | P11 | 公益服务通知扩展 | P0 | ✅ diff --git a/docs/Phase-08-V11-Completion-Report.md b/docs/Phase-08-V11-Completion-Report.md index c826f75..24d419d 100644 --- a/docs/Phase-08-V11-Completion-Report.md +++ b/docs/Phase-08-V11-Completion-Report.md @@ -1,5 +1,13 @@ # Phase 08 - Like / Match / Block Completion Report +> **2026-09-16 状态更新:本报告是 Phase 08 首轮(功能扩展)的交付记录,其"Known Issues 无"的结论已被复验推翻。** +> 2026-09-16 的 KEEP-VERIFY 复验在既有实现中发现 **3 处真实缺陷**: +> ① `DiscoverService.searchUsers` 不含查看者身份 → INV-004「搜索不可见」整体失效; +> ② 两条详情路径(`DiscoverService` / `UserProfileService`)均无 Block 判断; +> ③ `RelationshipService.like()` 把 `"liked"` 当 match source 写库 → 违反 `V003` 的 `chk_match_source`(测试库无该约束,故长期静默)。 +> **本报告中的 `MATCH_SOURCE*` 常量、`createMatch` 重载、`confirmActivityInterest`、Activity 二阶段确认等交付内容自始有效并继续沿用。** +> 复验结论与证据见 `docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`。 + ## Status Completed ✅ diff --git a/docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md b/docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md new file mode 100644 index 0000000..b27d1fe --- /dev/null +++ b/docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md @@ -0,0 +1,240 @@ +# 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 复用同一消息体系 diff --git a/docs/STATUS.md b/docs/STATUS.md index 6a36ee2..5994074 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,10 +1,10 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。** +> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08 RECOVERY CLOSED / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(299 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。 +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(308 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)复验关闭**,期间修复 3 处真实缺陷并补齐测试库的生产约束保真度。 -最后更新: 2026-09-16 — 分支 `main`(`bc4531a`;`feat-ui-v11` 保留为冗余锚点) +最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) ## 当前唯一有效状态 @@ -12,13 +12,14 @@ |---|---|---| | Phase 01~06 Recovery | 已完成 | 各 Phase Completion Report;38+9+3+4+21 后端专项通过 | | Phase 07 V1.1 首页业务与找对象 | **业务+UI 已通过用户验收**(2026-09-16) | HomeApplicationService 真实聚合;CapabilityService 独立化;discover 筛选完整重设计(5 chip + 4 section + 底部抽屉 + 滚动定位高亮);UI 按 2026-09-13 规格重新实现,11 页经用户验收通过 | +| **Phase 08 V1.1 Like / Match / Block** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 3 处真实缺陷:① `DiscoverService.searchUsers` 无查看者身份 → INV-004「搜索不可见」整体失效;② 详情路径无 Block 判断;③ `RelationshipService.like()` 把 `"liked"` 当 match source 写库 → 违反 `V003 chk_match_source`(H2 测试库无该约束故长期静默)。新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例,含反向拉黑);`schema-h2.sql` 补齐生产约束与 `user_profiles`;负向验证确认守卫会红。报告:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md` | | 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V035 已登记 | | 阶段 1 小程序 C 组 + discover | 已完成 | C 组 content/anti-fraud/service-station 迁 TS 唯一源;discover 筛选从参数到 UI 完整重写 | | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | | Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS | -| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**299 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest` 与新增 `MeRouteContractTest` | +| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**308 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest` 与新增 `DiscoverBlockInvisibilityIntegrationTest` | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | | `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | @@ -26,7 +27,7 @@ | UI 契约测试 | **13/13 通过** | 2026-09-16 运行:全部退出码 0;含 capability 独立化适配与文档基线口径 | | Git 工作区 | **已清零** | 阶段 0~4 共 24+ commit 可追溯;`git status --porcelain` 0 行 | | 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(299 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16) | -| UAT / RC / Production | **UI 验收通过,前后端契约已收敛** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛,可推进 Phase 08+ | +| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08 复验通过并关闭,可推进 Phase 09(**需用户确认后启动**) | --- @@ -136,7 +137,7 @@ | Miniapp 首页完整重构(U18 V1.1) | ✅ | | 5种用户状态首页适配 | ✅ | -### Phase 08 — Like / Match / Block ✅ 已完成 +### Phase 08 — Like / Match / Block ✅ 已完成(2026-09-16 复验关闭) | 任务 | 状态 | |------|------| | MATCH_SOURCE常量定义 | ✅ | @@ -146,6 +147,11 @@ | ActivityInterestController 新增confirm接口 | ✅ | | INV-014/015 不变量保障(不自动建Chat) | ✅ | | 通知文案更新(V1.1二阶段确认规则) | ✅ | +| **INV-004 搜索不可见(双向)修复** | ✅ 新增(复验发现原实现完全绕过 Block) | +| **详情路径 Block 拦截(双向 → 404)** | ✅ 新增(复验发现两条详情路径均无检查) | +| **`matches.source` 取值越界修复** | ✅ 新增(`"liked"` → `MATCH_SOURCE_USER_LIKE`) | +| **测试库保真度对齐(`chk_match_source` + `user_profiles`)** | ✅ 新增 | +| **`DiscoverBlockInvisibilityIntegrationTest`(9 用例)** | ✅ 新增 | ### Phase 09 — Text Chat 验证 ✅ 已完成 | 任务 | 状态 | diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index 9ca33ec..b683b13 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -181,13 +181,17 @@ **Gate:** 首页 5 秒识别品牌和主要入口;Banner 仅内部目标;首页真实聚合数据可用;小程序 TS 检查和开发者工具构建通过。 -### Phase 08:Like / Match / Block — KEEP-VERIFY/EXTEND +### Phase 08:Like / Match / Block — KEEP-VERIFY/EXTEND(2026-09-16 已完成) + +> 复验中发现并修复 3 处真实缺陷:搜索完全绕过 Block(INV-004「搜索不可见」整体失效)、 +> 详情页对双向拉黑者仍可见、`matches.source` 取值越界(实测 Mapper 传 `"liked"`,违反 `V003` 的 `chk_match_source`, +> 仅在 H2 测试库无该约束时才静默通过)。详见 `docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`。 **Files:** relationship 模块、discover/relationship 页面和对应测试。 -- [ ] 验证 Like、Pass、普通 Mutual、Unmatch、Block 全链路。 -- [ ] 增加 Activity Match Episode 兼容,不改变普通 Match 的双向 Like 规则。 -- [ ] 验证 Block 后推荐排除、搜索不可见、聊天停止且无法由缓存绕过。 +- [x] 验证 Like、Pass、普通 Mutual、Unmatch、Block 全链路。 +- [x] 增加 Activity Match Episode 兼容,不改变普通 Match 的双向 Like 规则。 +- [x] 验证 Block 后推荐排除、搜索不可见、聊天停止且无法由缓存绕过。 **Gate:** V1.0 核心回归通过;活动来源兼容不会创建第二套 Match 逻辑。