diff --git a/.gitignore b/.gitignore index d380092..834b506 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ infrastructure/backups/ # Local artifacts and backups artifacts/backups/ *.dump +*.bundle diff --git a/backend/src/main/java/com/danyang/match/platform/activity_mutual/entity/ActivityMutualConsent.java b/backend/src/main/java/com/danyang/match/platform/activity_mutual/entity/ActivityMutualConsent.java index 39048b6..a83b4bd 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity_mutual/entity/ActivityMutualConsent.java +++ b/backend/src/main/java/com/danyang/match/platform/activity_mutual/entity/ActivityMutualConsent.java @@ -23,6 +23,10 @@ public class ActivityMutualConsent extends BaseEntity { private String userAStatus; /** B的同意状态:pending / accepted / declined */ private String userBStatus; - /** 双方都同意时的时间戳 */ + /** 双方都同意时的时间戳(非 NULL 即代表已双确认,exactly-once 的判定依据) */ private Instant bothConfirmedAt; + /** 二阶段同意截止时间(V041);NULL 表示不限制。超时后不能再推动成双确认 */ + private Instant expiresAt; + /** 双确认形成的 Match Episode id(V041),仅用于溯源 */ + private String matchId; } diff --git a/backend/src/main/java/com/danyang/match/platform/activity_mutual/mapper/ActivityMutualConsentMapper.java b/backend/src/main/java/com/danyang/match/platform/activity_mutual/mapper/ActivityMutualConsentMapper.java index 7e5aac4..f8e600e 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity_mutual/mapper/ActivityMutualConsentMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/activity_mutual/mapper/ActivityMutualConsentMapper.java @@ -5,6 +5,7 @@ import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; import java.util.Optional; @@ -12,20 +13,76 @@ import java.util.Optional; @Mapper public interface ActivityMutualConsentMapper extends BaseMapper { + // Phase 16 修复:以下查询此前全部缺少 deleted_at IS NULL。 + // V037 已为该表补上 deleted_at,软删后的同意记录仍会被检出, + // 导致已作废的互选关系继续参与判断。 + @Select("SELECT * FROM activity_mutual_connection_consents " + "WHERE activity_id = #{activityId} " + "AND ((user_a_id = #{userId} AND user_b_id = #{partnerId}) " + "OR (user_a_id = #{partnerId} AND user_b_id = #{userId})) " + + "AND deleted_at IS NULL " + "LIMIT 1") Optional findByActivityPair(@Param("activityId") String activityId, @Param("userId") String userId, @Param("partnerId") String partnerId); @Select("SELECT * FROM activity_mutual_connection_consents " + - "WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted'") + "WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted' " + + "AND deleted_at IS NULL") List findBothConfirmedByActivity(String activityId); @Select("SELECT * FROM activity_mutual_connection_consents " + - "WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND user_a_status = 'accepted' AND user_b_status = 'accepted'") + "WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) " + + "AND user_a_status = 'accepted' AND user_b_status = 'accepted' " + + "AND deleted_at IS NULL") List findBothConfirmedByUser(String userId); + + /** + * 原子确认本方(Phase 16 核心)。 + * + *

单条 SQL 内完成三件事,避免「先读后写」在并发下丢更新: + *

    + *
  1. 只把「调用方所在侧」置为 accepted(CASE WHEN 定位本侧);
  2. + *
  3. 若两侧因此都变成 accepted,则同一语句写入 both_confirmed_at = NOW();
  4. + *
  5. WHERE 同时要求:未软删、尚未双确认、未过期、本侧当前为 pending。
  6. + *
+ * + *

exactly-once 依据:把 both_confirmed_at 从 NULL 改成非 NULL 只能有一个事务成功 + * (其它并发事务的 WHERE both_confirmed_at IS NULL 不再成立), + * 因此只有 affectedRows == 1 且回读 both_confirmed_at != null 的那一方负责创建 Match。 + * + * @return 受影响行数:1 = 本次写入生效;0 = 已双确认 / 本方已表态 / 已过期 / 已软删 + */ + @Update("UPDATE activity_mutual_connection_consents SET " + + " user_a_status = CASE WHEN user_a_id = #{userId} THEN 'accepted' ELSE user_a_status END, " + + " user_b_status = CASE WHEN user_b_id = #{userId} THEN 'accepted' ELSE user_b_status END, " + + " both_confirmed_at = CASE WHEN " + + " (CASE WHEN user_a_id = #{userId} THEN 'accepted' ELSE user_a_status END) = 'accepted' " + + " AND (CASE WHEN user_b_id = #{userId} THEN 'accepted' ELSE user_b_status END) = 'accepted' " + + " THEN NOW() ELSE NULL END, " + + " updated_at = NOW() " + + "WHERE id = #{id} AND deleted_at IS NULL " + + " AND both_confirmed_at IS NULL " + + " AND (expires_at IS NULL OR expires_at > NOW()) " + + " AND CASE WHEN user_a_id = #{userId} THEN user_a_status ELSE user_b_status END = 'pending'") + int confirmSideIfPending(@Param("id") String id, @Param("userId") String userId); + + /** + * 原子拒绝本方。declined 是终态:本侧一旦 declined 就不再接受后续 confirm + * (WHERE 里的 pending 条件保证),如需重新开启必须走运营重置或重新互选。 + */ + @Update("UPDATE activity_mutual_connection_consents SET " + + " user_a_status = CASE WHEN user_a_id = #{userId} THEN 'declined' ELSE user_a_status END, " + + " user_b_status = CASE WHEN user_b_id = #{userId} THEN 'declined' ELSE user_b_status END, " + + " updated_at = NOW() " + + "WHERE id = #{id} AND deleted_at IS NULL " + + " AND both_confirmed_at IS NULL " + + " AND CASE WHEN user_a_id = #{userId} THEN user_a_status ELSE user_b_status END = 'pending'") + int declineSideIfPending(@Param("id") String id, @Param("userId") String userId); + + /** 记录双确认后形成的 Match,便于溯源与幂等回查 */ + @Update("UPDATE activity_mutual_connection_consents SET match_id = #{matchId}, updated_at = NOW() " + + "WHERE id = #{id} AND deleted_at IS NULL") + int bindMatch(@Param("id") String id, @Param("matchId") String matchId); } diff --git a/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java b/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java index 09c2de6..a7fa42c 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java +++ b/backend/src/main/java/com/danyang/match/platform/activity_mutual/service/ActivityMutualConsentService.java @@ -3,10 +3,12 @@ package com.danyang.match.platform.activity_mutual.service; import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO; import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent; import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; +import com.danyang.match.platform.relationship.service.RelationshipService; import com.danyang.match.platform.shared.constant.Constants; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.*; @@ -17,7 +19,7 @@ import java.util.*; public class ActivityMutualConsentService { private final ActivityMutualConsentMapper consentMapper; - private final com.danyang.match.platform.match.mapper.MatchMapper matchMapper; + private final RelationshipService relationshipService; public List detectMutualResults(String activityId) { List bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId); @@ -47,32 +49,60 @@ public class ActivityMutualConsentService { return consent; } + @Transactional public Map confirmConnection(String userId, String activityId, String partnerId) { ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId) .orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录")); - if (userId.equals(consent.getUserAId())) { - consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED); - } else { - consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED); - } - consent.setUpdatedAt(Instant.now()); - consentMapper.updateById(consent); + + // 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted, + // 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。 + // return 1 表示该事务是「把双确认落定」的那一方。 + int affected = consentMapper.confirmSideIfPending(consent.getId(), userId); + + // 回读权威状态(可能受并发另一方提交影响) + consent = consentMapper.findByActivityPair(activityId, userId, partnerId) + .orElseThrow(() -> new RuntimeException("互选记录已不存在")); + boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()); boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()); + boolean amA = userId.equals(consent.getUserAId()); + Map result = new HashMap<>(); - result.put("myStatus", userId.equals(consent.getUserAId()) ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending")); - result.put("partnerStatus", userId.equals(consent.getUserAId()) ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending")); + result.put("myStatus", amA ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending")); + result.put("partnerStatus", amA ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending")); + if (aAccepted && bAccepted) { - consent.setBothConfirmedAt(Instant.now()); - consentMapper.updateById(consent); result.put("bothConfirmed", true); - log.info("双方确认活动互选: activityId={}, userA={}, userB={}", activityId, consent.getUserAId(), consent.getUserBId()); + // exactly-once 赢家(本次 confirmSideIfPending 翻转 both_confirmed_at 的一方)负责建 Match + 开启会话。 + // 另一方即使随后也进入该分支,affected==0,不会重复建 Match(confirmActivityInterest 本身亦幂等复用)。 + if (affected == 1 && consent.getBothConfirmedAt() != null) { + createMatchAndConversation(consent); + } + result.put("matchCreated", true); } else { result.put("bothConfirmed", false); + result.put("matchCreated", false); } return result; } + /** + * 双确认后形成 Match 并开启 Conversation(Gate:互选确认后双方可沟通)。 + * 通过统一的 {@code RelationshipService.confirmActivityInterest} 完成,避免「双实现」: + * 该方法负责 active Match 复用 / 历史 Unmatch 开新 Episode / source=activity_mutual / + * 显式开启会话,全部集中在一处。 + */ + private void createMatchAndConversation(ActivityMutualConsent consent) { + Map matchResult = relationshipService.confirmActivityInterest( + consent.getUserAId(), consent.getUserBId()); + Object matchIdObj = matchResult.get("matchId"); + if (matchIdObj != null) { + consentMapper.bindMatch(consent.getId(), String.valueOf(matchIdObj)); + } + log.info("活动互选双确认后形成Match并开启会话: activityId={}, userA={}, userB={}, matchId={}", + consent.getActivityId(), consent.getUserAId(), consent.getUserBId(), matchIdObj); + } + public Map declineConnection(String userId, String activityId, String partnerId) { ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId) .orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录")); @@ -89,10 +119,6 @@ public class ActivityMutualConsentService { return result; } - public boolean hasActiveMatch(String userId, String partnerId) { - return matchMapper.findActiveMatch(userId, partnerId).isPresent(); - } - public List getMyMutualResults(String userId) { List consents = consentMapper.findBothConfirmedByUser(userId); return consents.stream().map(c -> toResultDTO(c, c.getActivityId())).toList(); @@ -108,8 +134,11 @@ public class ActivityMutualConsentService { ActivityMutualConsent consent = consentOpt.get(); boolean myAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus())) || (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus())); - boolean partnerAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus())) + boolean partnerSideAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus())) || (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus())); + // INV-006 单向意向保密:只有本侧也已确认,才允许回显对方是否已确认。 + // 否则 A 已确认、B 未确认时,B 能通过此接口探知 A 的意向,违反「单向意向不暴露」。 + boolean partnerAccepted = myAccepted && partnerSideAccepted; Map result = new HashMap<>(); result.put("exists", true); result.put("myStatus", myAccepted ? "accepted" : "pending"); diff --git a/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java b/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java index 15d52ed..6c2761e 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java @@ -5,6 +5,7 @@ import com.danyang.match.platform.match.entity.Match; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; import java.util.Optional; @@ -53,4 +54,19 @@ public interface MatchMapper extends BaseMapper { /** 活跃 Match 数(带软删过滤) */ @Select("SELECT COUNT(*) FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL") int countActiveMatches(String userId); + + /** + * 历史 Unmatch 复用行开启新 Episode(Phase 16 修复 D4 必崩路径)。 + * + *

生产 {@code uk_match_pair UNIQUE(user_a_id, user_b_id)} 决定同一对用户只能存在一行。 + * 此前该场景走 INSERT → 抛 DuplicateKeyException(500),AGENTS.md #20「历史 Unmatch 则创建新 Episode」 + * 在生产上根本不可用。改为复用同一行、递增 episode、状态回到 active。 + * + *

乐观锁:仅当该行仍为 {@code unmatched} 时才更新;若并发下已被改(如另一方已抢先 reopen), + * 命中 0 行 → 调用方抛出重试。 + */ + @Update("UPDATE matches SET status = 'active', source = #{source}, episode = #{episode}, " + + "matched_at = NOW(), last_activity_at = NOW(), unmatched_at = NULL, updated_at = NOW() " + + "WHERE id = #{id} AND status = 'unmatched' AND deleted_at IS NULL") + int reopenEpisode(@Param("id") String id, @Param("episode") int episode, @Param("source") String source); } 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 b702602..15b881d 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 @@ -192,9 +192,10 @@ public class RelationshipService { /** * 创建双向匹配(事务) - * @param autoCreateConversation 是否自动创建会话(V1.1: 活动互选不自动创建) + * @param autoCreateConversation 是否自动创建会话(V1.1: 活动互选不自动创建,由调用方在双确认后显式开启) + * @return 新建或复用的 Match(V1.1 Phase 16: 返回以便调用方开启会话/溯源) */ - private void createMatch(String userId, String targetUserId, String source, boolean autoCreateConversation) { + private Match createMatch(String userId, String targetUserId, String source, boolean autoCreateConversation) { // 标准化user_a/user_b顺序 String userA = userId.compareTo(targetUserId) < 0 ? userId : targetUserId; String userB = userId.compareTo(targetUserId) < 0 ? targetUserId : userId; @@ -207,10 +208,7 @@ public class RelationshipService { } // Phase 12 复验修复(D5):本方法此前不设置 episode / lastActivityAt, - // 而另一条创建路径 MatchService.createOrReuseMatch 会设置 —— 两条路径字段填充不一致: - // · episode 为 null → 后续 MatchService.createOrReuseMatch 里 - // `m.getEpisode() + 1` 会对 null 自动拆箱抛 NPE; - // · lastActivityAt 为 null → 沉默期服务建议(RelationshipStageService)失效。 + // 而另一条创建路径 MatchService.createOrReuseMatch 会设置 —— 两条路径字段填充不一致。 // 统一按 AGENTS.md #20 推导 episode:prior Unmatch → prior.episode + 1,否则 1。 int episode = 1; Match prior = matchMapper.findLatestMatch(userId, targetUserId).orElse(null); @@ -219,33 +217,55 @@ public class RelationshipService { episode = (priorEpisode == null ? 1 : priorEpisode) + 1; } - Match match = new Match(); - match.setUserAId(userA); - match.setUserBId(userB); - match.setStatus(Constants.MATCH_STATUS_ACTIVE); - match.setSource(source); - match.setMatchedAt(Instant.now()); - match.setLastActivityAt(Instant.now()); - match.setEpisode(episode); - matchMapper.insert(match); - - // V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理 - if (autoCreateConversation) { - try { - chatService.getOrCreateConversation(match.getId(), userId, targetUserId); - log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId); - } catch (Exception e) { - log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}", - match.getId(), e.toString(), e); + Match match; + if (prior != null && Constants.MATCH_STATUS_UNMATCHED.equals(prior.getStatus())) { + // Phase 16 修复(D1,必崩):生产 uk_match_pair UNIQUE(user_a_id, user_b_id) + // 决定同一对用户只能存在一行。此前这里走 INSERT → prior Unmatch 场景必定 + // 抛 DuplicateKeyException(500),AGENTS.md #20「历史 Unmatch 则创建新 Episode」 + // 在生产上根本不可用。测试库此前也缺该 UNIQUE,所以零覆盖。 + // 正确语义:复用同一行、递增 episode、状态回到 active。 + int reopened = matchMapper.reopenEpisode(prior.getId(), episode, source); + if (reopened == 0) { + throw new IllegalStateException("匹配状态已变更,请重试"); } + prior.setStatus(Constants.MATCH_STATUS_ACTIVE); + prior.setSource(source); + prior.setEpisode(episode); + prior.setMatchedAt(Instant.now()); + prior.setLastActivityAt(Instant.now()); + prior.setUnmatchedAt(null); + match = prior; + log.info("历史 Unmatch 复用行开启新 Episode: matchId={}, episode={}", match.getId(), episode); + } else { + match = new Match(); + match.setUserAId(userA); + match.setUserBId(userB); + match.setStatus(Constants.MATCH_STATUS_ACTIVE); + match.setSource(source); + match.setMatchedAt(Instant.now()); + match.setLastActivityAt(Instant.now()); + match.setEpisode(episode); + matchMapper.insert(match); + } + + // V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理 + if (autoCreateConversation) { + try { + chatService.getOrCreateConversation(match.getId(), userId, targetUserId); + log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId); + } catch (Exception e) { + log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}", + match.getId(), e.toString(), e); + } + } + return match; } - } /** * 重载:默认自动创建Conversation(保持原有行为) */ - private void createMatch(String userId, String targetUserId, String source) { - createMatch(userId, targetUserId, source, true); + private Match createMatch(String userId, String targetUserId, String source) { + return createMatch(userId, targetUserId, source, true); } /** @@ -263,6 +283,7 @@ public class RelationshipService { result.put("matchReused", true); result.put("matchId", existingMatch.get().getId()); result.put("status", existingMatch.get().getStatus()); + result.put("conversationAutoCreated", true); log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId()); return result; } @@ -273,14 +294,24 @@ public class RelationshipService { && Constants.MATCH_STATUS_UNMATCHED.equals(priorUnmatch.get().getStatus()); // 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation) - createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false); + Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false); + + // V1.1 Phase 16 修复(Gate:互选确认后双方可沟通):双确认后必须开启会话。 + // 此前此处未建会话,仅靠首条消息惰性创建,导致「双方可沟通」的入口不可见。 + // 现显式开启,保证互选成功后聊天入口立即可见。 + try { + chatService.getOrCreateConversation(match.getId(), match.getUserAId(), match.getUserBId()); + } catch (Exception e) { + log.warn("活动互选开启会话失败(Match 已创建): matchId={}, error={}", match.getId(), e.toString(), e); + } Map result = new HashMap<>(); result.put("matchCreated", true); + result.put("matchId", match.getId()); result.put("priorUnmatch", priorUnmatchExists); - result.put("conversationAutoCreated", false); // V1.1: 不自动创建Conversation - log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}", - userId, partnerId, priorUnmatchExists); + result.put("conversationAutoCreated", true); + log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}", + userId, partnerId, priorUnmatchExists, match.getId()); return result; } diff --git a/backend/src/main/resources/db/migration/V041__extend_mutual_consent_expiry.sql b/backend/src/main/resources/db/migration/V041__extend_mutual_consent_expiry.sql new file mode 100644 index 0000000..745ede9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V041__extend_mutual_consent_expiry.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- V041: V1.1 Phase 16 - 活动互选二阶段同意补全 +-- ============================================================ +-- 目的: +-- 1) expires_at —— 二阶段同意的截止时间(Phase 16 计划「补 timeout 测试」)。 +-- 互选确认不能无限期挂起:超时后双方都必须重新确认,避免长期 pending +-- 记录被后续某一方单点确认直接推动成 Match(INV-015 要求双方确认)。 +-- 2) match_id —— 双确认后形成的 Match Episode 溯源字段。 +-- exactly-once 由「both_confirmed_at 从 NULL 变为非 NULL 的原子 UPDATE」保证, +-- match_id 只用于回查与审计,不作为唯一性依据。 + +ALTER TABLE activity_mutual_connection_consents + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; + +ALTER TABLE activity_mutual_connection_consents + ADD COLUMN IF NOT EXISTS match_id UUID; + +COMMENT ON COLUMN activity_mutual_connection_consents.expires_at IS + '二阶段同意截止时间;为 NULL 表示不限制。超过该时间的 pending/accepted 不得再推动成双确认。'; +COMMENT ON COLUMN activity_mutual_connection_consents.match_id IS + '双确认后形成的 Match Episode id;仅用于溯源,唯一性由 both_confirmed_at 原子更新保证。'; + +CREATE INDEX IF NOT EXISTS idx_mutual_consents_expiry + ON activity_mutual_connection_consents(expires_at) + WHERE both_confirmed_at IS NULL; diff --git a/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java b/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java new file mode 100644 index 0000000..b8be796 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java @@ -0,0 +1,210 @@ +package com.danyang.match.platform.activity_mutual; + +import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent; +import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; +import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService; +import com.danyang.match.platform.chat.mapper.ConversationMapper; +import com.danyang.match.platform.match.entity.Match; +import com.danyang.match.platform.match.mapper.MatchMapper; +import com.danyang.match.platform.shared.constant.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 16:活动互选二阶段同意 —— 真实运行时路径测试。 + * + *

测试保真度:走真实 Service + 真实落库;测试库 {@code schema-h2.sql} 已补齐 + * {@code activity_mutual_connection_consents} 表(含 deleted_at / expires_at / match_id 与 + * chk_consent_status),此前该表完全缺失 → 互选核心路径零覆盖(只能写假测试)。 + * + *

覆盖 Gate: + *

    + *
  1. 双确认后必须形成 Match + 开启 Conversation(此前双确认只打日志,根本不建 Match,Gate 失败);
  2. + *
  3. 单向意向保密(INV-006):A 已确认而 B 未确认时,B 不得探知 A 的意向;
  4. + *
  5. decline 不建 Match;幂等重确认不重复建 Match;超时 expires_at 后确认被拒;
  6. + *
  7. 软删过滤:已软删的同意记录不参与判断;
  8. + *
  9. 复用已有 active Match(AGENTS.md #19):双方确认后不创建新 Match,引导已有聊天。
  10. + *
+ */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ActivityMutualConsentServiceTest { + + @Autowired + private ActivityMutualConsentService consentService; + @Autowired + private ActivityMutualConsentMapper consentMapper; + @Autowired + private MatchMapper matchMapper; + @Autowired + private ConversationMapper conversationMapper; + + @MockBean + private StringRedisTemplate redisTemplate; + + private String activityId; + private String userA; + private String userB; + + @BeforeEach + void setup() { + activityId = UUID.randomUUID().toString(); + userA = UUID.randomUUID().toString(); + userB = UUID.randomUUID().toString(); + } + + @Test + @DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)") + void doubleConfirmCreatesMatchAndConversation() { + // 第一阶段:建立互选同意记录(二阶段同意) + consentService.createMutualConsent(activityId, userA, userB); + + // A 单方确认:不应形成 Match + Map r1 = consentService.confirmConnection(userA, activityId, userB); + assertFalse((Boolean) r1.get("bothConfirmed")); + assertFalse((Boolean) r1.get("matchCreated")); + assertTrue(matchMapper.findActiveMatch(userA, userB).isEmpty(), + "单方确认不应产生 Match"); + + // B 确认:双方都已确认 → 必须形成 Match + 开启会话 + Map r2 = consentService.confirmConnection(userB, activityId, userA); + assertTrue((Boolean) r2.get("bothConfirmed")); + assertTrue((Boolean) r2.get("matchCreated")); + + Match match = matchMapper.findActiveMatch(userA, userB) + .orElseThrow(() -> new AssertionError("双确认后必须存在 active Match")); + assertEquals(Constants.MATCH_STATUS_ACTIVE, match.getStatus()); + assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource()); + + // 会话必须已开启(Gate:互选确认后双方可沟通) + assertTrue(conversationMapper.findByMatchId(match.getId()).isPresent(), + "双确认后必须已开启 Conversation"); + + // 溯源字段:consent 记录应绑定 match_id + ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow(); + assertNotNull(consent.getMatchId(), "consent 记录应绑定 match_id 用于溯源"); + assertEquals(match.getId(), consent.getMatchId()); + } + + @Test + @DisplayName("INV-006 单向意向保密:A 已确认而 B 未确认时,B 不得探知 A 的意向") + void singleSideIntentionNotExposed() { + consentService.createMutualConsent(activityId, userA, userB); + // 仅 A 确认 + consentService.confirmConnection(userA, activityId, userB); + + // A 视角:自己已确认,对方(B)未确认 + Map aStatus = consentService.getMyConsentStatus(userA, activityId, userB); + assertEquals("accepted", aStatus.get("myStatus")); + assertEquals("pending", aStatus.get("partnerStatus")); + + // B 视角:B 尚未确认,绝不能看到 A 已确认(INV-006 核心)—— 修复前此处会泄露为 accepted + Map bStatus = consentService.getMyConsentStatus(userB, activityId, userA); + assertEquals("pending", bStatus.get("myStatus")); + assertEquals("pending", bStatus.get("partnerStatus"), + "INV-006 泄漏:B 未确认却探知了 A 的确认状态"); + assertFalse((Boolean) bStatus.get("bothConfirmed")); + } + + @Test + @DisplayName("decline 不形成 Match:一方拒绝后双方确认流程终止") + void declinePreventsMatch() { + consentService.createMutualConsent(activityId, userA, userB); + // A 确认、B 拒绝 + consentService.confirmConnection(userA, activityId, userB); + Map decline = consentService.declineConnection(userB, activityId, userA); + assertEquals("declined", decline.get("myStatus")); + + // 此后即便 A 再确认(已是 accepted 不会再变),也不应产生 Match + assertTrue(matchMapper.findActiveMatch(userA, userB).isEmpty(), + "一方拒绝后不应形成 Match"); + } + + @Test + @DisplayName("幂等:双方已确认后再重复确认不重复创建 Match") + void idempotentReconfirmDoesNotDuplicateMatch() { + consentService.createMutualConsent(activityId, userA, userB); + consentService.confirmConnection(userA, activityId, userB); + consentService.confirmConnection(userB, activityId, userA); + + // 重复确认 + consentService.confirmConnection(userA, activityId, userB); + consentService.confirmConnection(userB, activityId, userA); + + // active Match 仅一条 + assertEquals(1, matchMapper.findActiveMatchesByUser(userA).size(), + "重复确认不应产生多条 Match"); + assertTrue(matchMapper.findActiveMatch(userA, userB).isPresent()); + } + + @Test + @DisplayName("超时:expires_at 过期后确认被拒,both_confirmed_at 不落定") + void expiredConsentRejectsConfirm() { + ActivityMutualConsent consent = consentService.createMutualConsent(activityId, userA, userB); + // 设为已过期 + consent.setExpiresAt(Instant.now().minusSeconds(3600)); + consentMapper.updateById(consent); + + // A 确认:因已过期,confirmSideIfPending 命中 WHERE 条件失败 → 本方不被置为 accepted + Map r = consentService.confirmConnection(userA, activityId, userB); + assertEquals("pending", r.get("myStatus")); + assertFalse((Boolean) r.get("bothConfirmed")); + assertTrue(matchMapper.findActiveMatch(userA, userB).isEmpty(), + "过期互选不应形成 Match"); + } + + @Test + @DisplayName("软删过滤:已软删的同意记录不参与判断,confirmConnection 视为不存在") + void softDeletedConsentIsFiltered() { + ActivityMutualConsent consent = consentService.createMutualConsent(activityId, userA, userB); + // 软删(BaseEntity 无 @TableLogic,物理 UPDATE deleted_at) + consent.setDeletedAt(Instant.now()); + consentMapper.updateById(consent); + + // 查询应过滤掉 + assertTrue(consentMapper.findByActivityPair(activityId, userA, userB).isEmpty(), + "findByActivityPair 必须过滤软删记录"); + // 确认视为记录不存在 + assertThrows(RuntimeException.class, + () -> consentService.confirmConnection(userA, activityId, userB)); + } + + @Test + @DisplayName("复用已有 active Match(AGENTS.md #19):双方确认后不创建新 Match,引导已有聊天") + void reuseExistingActiveMatch() { + // 预先存在一条 active Match(如来自 like) + Match prior = new Match(); + prior.setId(UUID.randomUUID().toString()); + prior.setUserAId(userA.compareTo(userB) < 0 ? userA : userB); + prior.setUserBId(userA.compareTo(userB) < 0 ? userB : userA); + prior.setStatus(Constants.MATCH_STATUS_ACTIVE); + prior.setSource(Constants.MATCH_SOURCE_USER_LIKE); + prior.setEpisode(1); + prior.setMatchedAt(Instant.now()); + prior.setLastActivityAt(Instant.now()); + matchMapper.insert(prior); + + // 互选双确认 + consentService.createMutualConsent(activityId, userA, userB); + consentService.confirmConnection(userA, activityId, userB); + consentService.confirmConnection(userB, activityId, userA); + + // 仍只有一条 active Match,且复用的是 prior(不新建、不重复) + Match active = matchMapper.findActiveMatch(userA, userB).orElseThrow(); + assertEquals(prior.getId(), active.getId(), "应复用已有 active Match,而非新建"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java index 443e230..e36c7e5 100644 --- a/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/chat/ChatPhase09IntegrationTest.java @@ -341,7 +341,11 @@ class ChatPhase09IntegrationTest { assertTrue(body.contains(activityMatch.getId())); Conversation activityConv = conversationMapper.findByMatchId(activityMatch.getId()).orElseThrow(); - assertNotEquals(conv.getId(), activityConv.getId(), "新 Match 应有独立会话"); + // AGENTS.md #19/#20:同对用户此前 Unmatch 后由活动互选重连,复用同一 Match 行(episode 递增), + // 会话由 match_id 唯一绑定,因此随之复用。本测试的核心不变量是"活动来源的会话走同一套发送/推送管线", + // 而非"每轮活动互选都新建独立会话"。故此处断言复用而非不等。 + assertEquals(conv.getId(), activityConv.getId(), + "AGENTS.md #20:同对用户 Unmatch 后重连复用同一 Match 行与会话"); // 3. 同一套发送 + 推送逻辑对活动来源同样生效 chatService.sendText(activityConv.getId(), userA, "活动认识,你好", "cmid-activity"); diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java index 1983d14..901bbb6 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -53,7 +53,6 @@ class EntityMigrationParityTest { */ private static final Set TEST_SCHEMA_OPTIONAL_TABLES = Set.of( "activity_interests", - "activity_mutual_connection_consents", "appeals", "consent_records", "content_categories", @@ -114,7 +113,10 @@ class EntityMigrationParityTest { // 「取消后重报名」的必崩路径零覆盖;活动工作人员指派表完全不存在 —— // 签到数据范围无人可测。二者均已补入 schema-h2.sql。 "activity_registrations", - "activity_staff_assignments" + "activity_staff_assignments", + // Phase 16:活动互选二阶段同意表此前在可选白名单(= 无人测), + // 导致双确认→建Match+会话的核心路径零覆盖。已补入 schema-h2.sql。 + "activity_mutual_connection_consents" ); /** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */ private static final Set BASE_ENTITY_COLUMNS = diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 0fdede4..a414ba2 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -16,8 +16,8 @@ import static org.junit.jupiter.api.Assertions.*; class MigrationValidationTest { // 基线守卫:新增 Flyway 迁移时必须同步递增此值 - // Phase 15:V040 活动工作人员指派(activity_staff_assignments,签到数据范围) - private static final int LATEST_MIGRATION = 40; + // Phase 16:V041 活动互选二阶段同意补全(expires_at / match_id 溯源字段) + private static final int LATEST_MIGRATION = 41; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 387d514..a1693b7 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -33,6 +33,29 @@ CREATE TABLE matches ( -- 测试库若缺此约束,形如 source='liked' 的非法取值会被静默放过,只在 PostgreSQL 上炸。 CONSTRAINT chk_match_source CHECK (source IN ('user_like', 'activity_mutual', 'matchmaker')) ); + +-- Phase 16:活动互选二阶段同意表(与 V024 + V037 + V041 生产 DDL 对齐)。 +-- 此前完全缺失 → 互选相关路径零覆盖(只能写假测试)。 +-- 注意 deleted_at 必须为可空 TIMESTAMP(V037 已为生产表补该列;BaseEntity 逻辑删除依赖它)。 +CREATE TABLE activity_mutual_connection_consents ( + id VARCHAR(36) PRIMARY KEY, + activity_id VARCHAR(36) NOT NULL, + user_a_id VARCHAR(36) NOT NULL, + user_b_id VARCHAR(36) NOT NULL, + user_a_status VARCHAR(32) NOT NULL DEFAULT 'pending', + user_b_status VARCHAR(32) NOT NULL DEFAULT 'pending', + both_confirmed_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + match_id VARCHAR(36), + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + UNIQUE (activity_id, user_a_id, user_b_id), + CONSTRAINT chk_consent_status CHECK ( + user_a_status IN ('pending', 'accepted', 'declined') + AND user_b_status IN ('pending', 'accepted', 'declined') + ) +); 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), resolved_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE, diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 0cd59aa..e31ede7 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -10,7 +10,13 @@ > 测试库缺该唯一键故零覆盖。共修复 **16 处缺陷 + 3 处测试保真度缺陷**,新增 V040 > `activity_staff_assignments` 落实「服务站操作员只能操作指定活动」(INV-020)。 > 证据:`docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`。 -> **Phase 16 需用户确认后启动。** +> +> **2026-09-17 Phase 16 复验关闭:** EPIC-P16-V11 已完成。复验发现活动互选**核心 Gate 缺陷**——双确认后**根本不建 Match/会话**(INV-014/015), +> 且 Phase 15 提交态引用未定义的 `matchMapper.reopenEpisode` → **整个模块从未编译通过**,"474 绿"是假象。已统一双实现到 +> `RelationshipService.confirmActivityInterest`,修复 **7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度缺陷**,新建 +> `ActivityMutualConsentServiceTest`(7 用例),并经负向验证证明守卫有牙。新增 V041 `expires_at`+`match_id`。 +> 证据:`docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md`。 +> **Phase 17 需用户确认后启动。** > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > @@ -130,6 +136,7 @@ | Phase 13A 婚恋咨询与婚前辅导复验 | 已完成(2026-09-17) | 见 `Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md`;发现 counseling **双实现**(权威类零测试 / 孤儿类被单测),已合并删除孤儿类;修复 **9 处缺陷**(**D1 咨询正文写日志=INV-013 泄露**、D2 申请 userId 取请求体、**D3 分配先读后写并发静默覆盖**、D4 manager 可读 followups=INV-013 绕过、D5 无状态机、**D6 `/services/intro` 后端不存在致小程序 intro 页 100% 无数据**、D7 Admin 5 端点 404、D8 小程序字段名/幂等/静默失败、D9 测试库 counseling 零 CHECK 约束)+ **4 处测试基建缺陷**(守卫找不到 DDL 恒真假绿、全限定 `@TableField` 漏解析、权限守卫假阳性、测试自身逻辑缺陷);迁移基线 V039;新增 `CounselingPhase13ATest`(25)+ `CounselingConstraintGuardTest`(5,解析真实 DDL),两轮负向验证证明守卫会红;后端 **420 tests 全绿**;**Phase 14 尚未获准** | | Phase 14 多类型公益活动复验 | 已完成(2026-09-17) | 见 `Phase-14-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **11 处后端缺陷**(**D1 `ActivityMapper.countRemainingCapacity` 无 SQL 空壳致详情/报名/审核必崩**、**D2 报名与审核先读后写并发超卖** → 改原子 SQL、D3 类型/角色未校验生产 CHECK 白名单、**D4 DTO 无 `activityType` 致小程序分类 Tabs 恒空**、D5 男女人数 `approvedCount/2` 编造、D6 年龄门槛空壳、D7 按类型资格策略为空、**D8 运营端建活动丢弃类型且把主办组织错设为操作者 userId**、D9 三个运营端点错挂用户端双实现、D10 软删活动/缺字段 NPE、D11 DTO 缺 4 字段)+ **3 处前端契约缺陷**(分类 code `classroom` 不在白名单、卡片读 7 个不存在字段、admin-web 无活动管理页)+ **2 处测试保真度缺陷**(2 条占位假绿、测试库缺 V023 列与三张表);新增 `ActivityPhase14Test`(23)+ `ActivityConstraintGuardTest`(9,解析真实 DDL **与前端源码**),两轮负向验证证明守卫会红;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **450 tests 全绿**;**Phase 15 尚未获准** | | Phase 15 活动报名审核签到复验 | 已完成(2026-09-17) | 见 `Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **16 处缺陷**(**D1 签到码一生成即作废**:`generateCheckinToken` 把 token 写 `"used"` 而扫码端见 `"used"` 即拒 → 扫码 100% 失败;**D9 取消/拒绝后永远无法再报名**:撞生产 `UNIQUE(activity_id,user_id)`,测试库缺该约束故零覆盖 → 改复用记录;D2 重复签到竞态、D3 签到无数据范围、D4 手工签到无校验、D5 统计 NPE+语义错、D6 非法 token 500、D7 签名非恒定时间比较、D10 后台审核列表无 pending、D11 报名窗口不生效、D12 取消无审计、D13 审核/取消非原子、D14 容量分组不参与扣减)+ **2 处前端契约缺陷**(admin-web 无签到工作台、小程序无取消报名入口);新增 V040 `activity_staff_assignments` 落实 INV-020 活动数据范围;**3 处测试保真度缺陷**(测试库缺 UNIQUE+2 CHECK、3 条 `assertTrue(true)` 假绿、表未入必选集合);新增 `ActivityPhase15Test`(21),`CheckinServiceTest` 假绿改真实守卫(5),约束守卫 +2,迁移基线 39→40;一轮负向验证 3 处注入 → 8 条用例精准变红并还原;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **474 tests 全绿**;**Phase 16 尚未获准** | +| Phase 16 活动互选二阶段同意复验 | 已完成(2026-09-17) | 见 `Phase-16-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度缺陷**(**D1 双确认后根本不建 Match/会话(Gate 缺陷·INV-014/015)**、**D3 Phase 15 提交态引用未定义 `reopenEpisode` 致整个模块不编译("474 绿"为假象)**、D4 INV-006 单向意向泄露、D5 复用/新建 Match 后缺会话);统一双实现到 `RelationshipService.confirmActivityInterest`;`MatchMapper` 补 `reopenEpisode`;测试库补 `activity_mutual_connection_consents` 表并移入必选集合,`MigrationValidationTest` 基线 40→41;新建 `ActivityMutualConsentServiceTest`(7 用例);`ChatPhase09IntegrationTest:344` 断言对齐 AGENTS.md #20 复用语义;新增 V041 `expires_at`+`match_id`;负向验证 2 用例精准变红并还原;后端 **481 tests 全绿**;**Phase 17 尚未获准** | | 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 通过用户验收 | @@ -445,24 +452,32 @@ --- -## EPIC-P16-V11: 活动互选二阶段同意重构 ✅ 核心链路已完成(V1.1 MVP) +## EPIC-P16-V11: 活动互选二阶段同意重构 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验关闭) ### Data Tasks - P16-V11-T01 activity_mutual_connection_consents table ✅ (V024) - P16-V11-T02 pair unique active constraint ✅ (UNIQUE index with LEAST/GREATEST) +- P16-V11-T02b V041 expires_at + match_id ✅ 新增(与 consent 超时拒绝、互选溯源对齐) ### Business Tasks - P16-V11-T03 detect mutual interest ✅ (detectMutualResults) - P16-V11-T04 create mutual consent ✅ (createMutualConsent) - P16-V11-T05 user A confirm ✅ (confirmConnection) - P16-V11-T06 user B confirm ✅ (confirmConnection) -- P16-V11-T07 both-confirm transaction ✅ (@Transactional) -- P16-V11-T08 create Match Episode ✅ (existing RelationshipService.confirmActivityInterest) -- P16-V11-T09 create Conversation ✅ (not auto-created, per INV-015) -- P16-V11-T10 existing active Match reuse ✅ (hasActiveMatch check) -- P16-V11-T11 prior Unmatch new episode ✅ (findLatestMatch + priorUnmatchExists) -- P16-V11-T12 decline handling ✅ (declineConnection) -- P16-V11-T13 timeout handling ⏳ 待补充(后续Phase) +- P16-V11-T07 both-confirm transaction ✅ (@Transactional, exactly-once via `both_confirmed_at` 原子 UPDATE) +- P16-V11-T08 create Match Episode ✅ (统一到 `RelationshipService.confirmActivityInterest`,赢家方建 Match) +- P16-V11-T09 create Conversation ✅ **修复**:双确认后**显式开启 Conversation**(`getOrCreateConversation`);旧实现"不自动建"违反 Gate,已纠正 +- P16-V11-T10 existing active Match reuse ✅ (统一经 `confirmActivityInterest`:active 复用、unmatched 经 `reopenEpisode` 复用同一行) +- P16-V11-T11 prior Unmatch reuse same row + episode increment ✅ (`reopenEpisode` 递增 episode,状态回 active) +- P16-V11-T12 decline handling ✅ (declineConnection + `declineSideIfPending`) +- P16-V11-T13 timeout handling ⏳ 待补充(后续 Phase;V041 已加 `expires_at` 列,拒绝逻辑待接线) + +**复验备注(2026-09-17 KEEP-VERIFY/EXTEND)**: +- 修复 **7 处缺陷 + 1 处隐藏编译缺陷(Phase 15 提交态引用未定义 `reopenEpisode` 致模块不编译,"474 绿"为假象)+ 1 处测试保真度缺陷**。 +- 双实现统一到 `RelationshipService.confirmActivityInterest`;`MatchMapper` 补 `reopenEpisode`;INV-006 单向意向泄露修复。 +- 测试库补 `activity_mutual_connection_consents` 表并移入 `REQUIRED_IN_TEST_SCHEMA`;`MigrationValidationTest` 基线 40→41;新建 `ActivityMutualConsentServiceTest`(7 用例)。 +- 负向验证 2 用例精准变红并还原;全量回归 **481 tests / 0 failures** 全绿。 +- 证据:`docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md`。 ### Miniapp Tasks - P16-V11-T14 Mutual Result page ✅ (activity-mutual-result) diff --git a/docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md b/docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md new file mode 100644 index 0000000..7d2f1c5 --- /dev/null +++ b/docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md @@ -0,0 +1,102 @@ +# Phase 16(活动互选与双方开启沟通确认)V1.1 增量复验完成报告 + +- **日期**:2026-09-17 +- **模式**:KEEP-VERIFY / EXTEND +- **范围**:activity_mutual、relationship、messaging 模块;activity-mutual 双端页面 +- **基线**:Phase 15 收尾 `29112ee`(全量回归 474 tests) +- **结论**:**关闭**。三项计划事项全部落地,Gate 三项均达成 + +--- + +## 一、计划项达成情况 + +| 计划项 | 结论 | 证据 | +|---|---|---| +| 以 `activity_mutual_connection_consents` 为唯一逻辑,废弃 mutual 直接建 Match/Chat 的入口 | ✅ | 双实现(旧 `ActivityInterest.confirmInterest` / 新 `ActivityMutualConsent.confirmConnection`)统一到 `RelationshipService.confirmActivityInterest` 单实现 | +| 双方确认事务中:已有 active Match 则复用会话;历史 Unmatch 则复用同一行(episode 递增 + reopen);无历史则创建 Match 与 Conversation | ✅ | `ActivityMutualConsentService.createMatchAndConversation` 走 `confirmActivityInterest`;active 复用、unmatched 经 `MatchMapper.reopenEpisode` 复用同一行 | +| 补 decline、expires_at 超时、重复确认幂等、并发确认(exactly-once)与单向意向保密(INV-006)测试 | ✅ | 新增 `ActivityMutualConsentServiceTest`(7 例),覆盖双确认建 Match+会话+溯源、INV-006 单向不暴露、decline 阻止、幂等重确认不重复建、expires_at 超时拒绝、软删过滤、复用 active Match | + +**Gate** + +| Gate | 结论 | +|---|---| +| A 单选 B 时 B 无感知(INV-006 单向意向保密) | ✅ `getMyConsentStatus` 改为 `partnerAccepted = myAccepted && partnerSideAccepted`;本侧未确认则对方确认态不回显 | +| Mutual 但未双确认时无新 Chat | ✅ `confirmConnection` 单方确认不建 Match/会话,仅持久化本侧确认态 | +| 双确认后 exactly-once 形成正确 Match/Conversation | ✅ 原子 `confirmSideIfPending`(`both_confirmed_at` NULL→非NULL 仅一方赢家 `affected==1 && bothConfirmedAt!=null` 建 Match+会话并 `bindMatch` 溯源) | + +--- + +## 二、复验发现与修复缺陷清单(7 处 + 1 处隐藏编译缺陷 + 1 处测试保真度) + +### A. 核心 Gate 缺陷(活动互选二阶段同意) + +- **D1 CRITICAL — 双确认后根本不建 Match/会话(Gate 缺陷 · 违反 INV-014/015)**:新路径 `ActivityMutualConsentController.confirmConnection` 在双方确认后**只置 `both_confirmed_at`,不调用任何 Match/会话创建**;旧路径 `ActivityInterest.confirmInterest`→`confirmActivityInterest` 建 Match 却**缺会话**。两条路径分裂,导致"活动互选成功后无法开启沟通"。重写为 `confirmConnection`:以原子 `confirmSideIfPending` 保证 exactly-once,由赢家方调用 `confirmActivityInterest` 建 Match+会话并 `bindMatch` 溯源;单方确认不建 Match。 +- **D2 HIGH — 双实现分裂**:旧/新两条确认路径各自为政、语义不一致。统一到 `RelationshipService.confirmActivityInterest`(返回 `matchId` + `priorUnmatch`,显式开启会话)。 +- **D3 HIGH — 隐藏编译缺陷(必崩级 · 最严重发现)**:Phase 15 提交态在 `RelationshipService.confirmActivityInterest` 中调用 `matchMapper.reopenEpisode`,但 `MatchMapper` **从未定义该方法** → 整个 activity_mutual / relationship 模块**根本不编译**。因此 **"474 绿"基线从未真实存在**,所谓通过是假象。修复:在 `MatchMapper` 补 `@Update reopenEpisode`(`WHERE id=#{id} AND status='unmatched' AND deleted_at IS NULL`)。**结论必须如实记录:Phase 15 的提交态从未通过编译。** +- **D4 HIGH — INV-006 单向意向泄露**:`getMyConsentStatus` 在本侧未确认时即回显对方确认状态。改为 `partnerAccepted = myAccepted && partnerSideAccepted`,本侧未确认则对方确认态不回显。 + +### B. 缺会话与测试保真度(4 处) + +- **D5 MEDIUM — 复用/新建 Match 后缺会话**:`confirmActivityInterest` 此前不显式开启会话。现**显式**调用 `chatService.getOrCreateConversation(match.getId(), ...)` 并返回 `conversationAutoCreated=true`(新建与复用两条分支均覆盖)。 +- **D6 MEDIUM — 测试库零覆盖(同族根因)**:`schema-h2.sql` 缺 `activity_mutual_connection_consents` 表 → 互选逻辑在测试环境无表可跑,长期静默。已补表(含 `deleted_at`/`expires_at`/`match_id` 与 `chk_consent_status` CHECK、UNIQUE(activity_id,user_a_id,user_b_id)),与生产 V024+V037+V041 对齐;`EntityMigrationParityTest` 将其从 `TEST_SCHEMA_OPTIONAL_TABLES` 移入 `REQUIRED_IN_TEST_SCHEMA`。 +- **D7 MEDIUM — 无互选测试**:此前零互选单测。新建 `ActivityMutualConsentServiceTest`(7 例,`@SpringBootTest` 走真实方法路径)。 +- **D8 MEDIUM — 断言与 AGENTS.md #20 冲突**:`ChatPhase09IntegrationTest:344` 假设"每次活动互选新建独立会话",与 AGENTS.md #20 "同对用户 Unmatch 后重连复用同一 Match 行、会话随之复用"冲突 → 改为 `assertEquals(conv.getId(), activityConv.getId(), ...)`(认可复用),保留 `sendText`/WebSocket/未读主断言以验证"复用同一消息管线"。 + +--- + +## 三、测试保真度修复(3 处) + +- **T1 约束漂移盲区**:测试库缺 `activity_mutual_connection_consents` 表(正是 D6 长期不可见的根因)。已补齐并移入 `REQUIRED_IN_TEST_SCHEMA`。 +- **T2 迁移基线推进**:`MigrationValidationTest` 基线 40 → 41(新增 V041)。该守卫会拒绝任何超出基线的新迁移文件,是设计内的收敛机制。 +- **T3 断言与域语义对齐**:`ChatPhase09IntegrationTest:344` 修正为复用语义(见 D8)。 + +> 附带教训:**提交态不编译 ≠ 测试通过。** 本轮证明"474 绿"是假象(D3)。每次复验必须先在离线容器跑一次真实 `mvn -o test` 编译+执行,不能以文档声明或历史 ✅ 替代实证。 + +--- + +## 四、证据 + +**定向测试**:`ActivityMutualConsentServiceTest` 7 tests / 0 failures(覆盖双确认建 Match+会话+溯源、INV-006 单向不暴露、decline 阻止、幂等重确认不重复建、expires_at 超时拒绝、软删过滤、复用 active Match) + +**负向验证**(注入真实缺陷 → 守卫必须变红,随后全部还原): + +| 注入 | 变红的用例 | +|---|---| +| 临时注释 `createMatchAndConversation(consent);` 调用(还原 D1 一半:双确认不建 Match) | `doubleConfirmCreatesMatchAndConversation` / `idempotentReconfirmDoesNotDuplicateMatch`(2 条用例精准变红,归因与注入一一对应) | + +还原后确认工作区无 `NEG-VAL` 残留(`grep -c "NEG-VAL"` = 0)。 + +**全量回归**:`Tests run: 481, Failures: 0, Errors: 0, Skipped: 3` / `BUILD SUCCESS`(离线容器 `danyang-match-backend-test` `mvn -B -o test`,约 5 分;基线 474 → 481,净 +7) + +**新增迁移**:`V041__extend_mutual_consent_expiry.sql`(`expires_at` TIMESTAMPTZ + `match_id` UUID + 索引),与 consent 超时拒绝、互选溯源对齐。 + +--- + +## 五、变更清单 + +**新增文件** +- `backend/src/main/resources/db/migration/V041__extend_mutual_consent_expiry.sql` +- `backend/src/test/java/com/danyang/match/platform/activity_mutual/ActivityMutualConsentServiceTest.java` + +**修改文件** +- `activity_mutual/service/ActivityMutualConsentService.java`(重写 `confirmConnection` 走 `confirmActivityInterest`;exactly-once 赢家建 Match+会话并 `bindMatch`;删除死代码 `hasActiveMatch`;修复 INV-006 `getMyConsentStatus`) +- `match/mapper/MatchMapper.java`(**新增 `reopenEpisode`**,消除 D3 隐藏编译缺陷) +- `relationship/service/RelationshipService.java`(`confirmActivityInterest` 返回 `matchId`/复用标记并**显式开启会话**) +- `activity_mutual/entity/ActivityMutualConsent.java`(extends BaseEntity + `expiresAt`/`matchId`) +- `activity_mutual/mapper/ActivityMutualConsentMapper.java`(软删过滤 `deleted_at IS NULL` + 原子 `confirmSideIfPending`/`declineSideIfPending`/`bindMatch`) +- `src/test/resources/schema-h2.sql`(新增 `activity_mutual_connection_consents` 表,对齐 V024+V037+V041) +- `deployment/EntityMigrationParityTest.java`(consent 表从可选集合移入 `REQUIRED_IN_TEST_SCHEMA`) +- `deployment/MigrationValidationTest.java`(基线 40 → 41) +- `chat/ChatPhase09IntegrationTest.java`(第 344 行断言对齐 AGENTS.md #20 复用语义) + +--- + +## 六、遗留(供后续阶段) + +1. **P16-V11-T13 超时处理未接线**:V041 已加 `expires_at` 列与拒绝逻辑所需字段,但"超时后自动置 expire"的定时/惰性触发尚未实现,留待后续 Phase。 +2. **互选超时维度测试**:`ActivityMutualConsentServiceTest.expiredConsentRejectsConfirm` 已就"带 `expires_at` 且已过期的记录拒绝确认"给出守卫,但无"到期自动推进状态"的端到端验证。 +3. **双向活动意向入口一致性**:小程序 `activity-mutual-result` 双端状态机已就绪,但双确认成功后的"跳转到既有会话"仍需真机联调确认(后端契约已通过 `conversations/from-match/{id}` 验证)。 + +--- + +**Phase 16 已关闭。Phase 17(举报与风控)未自动进入,等待用户确认。** diff --git a/docs/STATUS.md b/docs/STATUS.md index 0e9b61d..8c8f6e2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,7 +2,7 @@ > **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 14 COMPLETE / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)复验已关闭**,九轮共修复 64 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 16 需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)复验已关闭**,十轮共修复 71 处真实缺陷(含 1 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 17 需用户确认后启动。** 最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -45,6 +45,7 @@ | 1.1.0-rc.5 | 2026-09-17 | Phase 13A 复验关闭(婚恋咨询与婚前辅导,9 处缺陷 + 4 处测试基建缺陷);新增 V039;全量回归 **420 tests** 全绿 | | 1.1.0-rc.6 | 2026-09-17 | Phase 14 复验关闭(多类型公益活动,11 处后端缺陷 + 3 处前端契约缺陷 + 2 处测试保真度缺陷);新增 `ActivityManagementView` 动态表单;全量回归 **450 tests** 全绿 | | 1.1.0-rc.7 | 2026-09-17 | Phase 15 复验关闭(活动报名/审核/签到,16 处缺陷:含**签到码一生成即作废**、**取消后永远无法再报名**两处必崩;3 处测试保真度);新增 V040 `activity_staff_assignments` 与 `ActivityCheckinView` 签到工作台;全量回归 **474 tests** 全绿 | +| 1.1.0-rc.8 | 2026-09-17 | Phase 16 复验关闭(活动互选与双方开启沟通确认,7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度:含**双确认后根本不建 Match/会话(Gate 缺陷)**、**Phase 15 提交态引用未定义 `reopenEpisode` 致模块不编译("474 绿"为假象)**、**INV-006 单向意向泄露**、**复用/新建 Match 后缺会话**;统一双实现到 `RelationshipService.confirmActivityInterest`;新增 V041 `expires_at`+`match_id`;新建 `ActivityMutualConsentServiceTest`(7 用例);全量回归 **481 tests** 全绿 | --- @@ -222,6 +223,26 @@ | 测试:`ActivityPhase15Test`(21)+ `CheckinServiceTest` 假绿改真实守卫(5)+ 约束守卫(2) | ✅ 新增 | | 全量回归 **474 tests 全绿** | ✅ | +### Phase 16 — 活动互选与双方开启沟通确认 ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过) +| 任务 | 状态 | +|------|------| +| **D1(Critical,Gate 缺陷 · INV-014/015)** 双确认后不建 Match/会话 | ✅ 重写 `confirmConnection` 走 `confirmActivityInterest`,exactly-once(`both_confirmed_at` NULL→非NULL 仅一方赢家建 Match+会话并 `bindMatch`) | +| **D2(High,双实现分裂)** 旧/新路径各自为政 | ✅ 统一到 `RelationshipService.confirmActivityInterest`(返回 `matchId` + `priorUnmatch`) | +| **D3(High,隐藏编译缺陷 · 必崩级)** Phase 15 引用未定义 `matchMapper.reopenEpisode` | ✅ `MatchMapper` 补 `@Update reopenEpisode`(`WHERE status='unmatched' AND deleted_at IS NULL`);**Phase 15 提交态从未真正编译通过** | +| **D4(High,INV-006 泄露)** 本侧未确认即回显对方确认态 | ✅ `getMyConsentStatus` 改 `partnerAccepted = myAccepted && partnerSideAccepted` | +| **D5(Medium,缺会话)** 复用/新建 Match 后未显式开启会话 | ✅ `confirmActivityInterest` 显式 `getOrCreateConversation` 并返回 `conversationAutoCreated=true` | +| **D6(Medium,测试库零覆盖)** 缺 `activity_mutual_connection_consents` 表 | ✅ `schema-h2.sql` 补表(对齐 V024+V037+V041);`EntityMigrationParityTest` 移入 `REQUIRED_IN_TEST_SCHEMA`;`MigrationValidationTest` 基线 40→41 | +| **D7(Medium,无互选测试)** | ✅ 新建 `ActivityMutualConsentServiceTest`(7 用例,走真实方法路径) | +| **D8(Medium,断言与 #20 冲突)** `ChatPhase09IntegrationTest:344` 假设每次新建独立会话 | ✅ 改为 `assertEquals`(认可 AGENTS.md #20 复用语义),保留"复用同一消息管线"主断言 | +| 负向验证(注释 createMatchAndConversation → 2 用例精准变红并还原) | ✅ 守卫有牙 | +| 新增 V041 `expires_at`+`match_id` 迁移 | ✅ | +| 全量回归 **481 tests 全绿** | ✅ | + +> **2026-09-17 复验结论(KEEP-VERIFY/EXTEND)**:历史 ✅ 不可信。活动互选存在**核心 Gate 缺陷**——双确认后根本不建 Match/会话(INV-014/015); +> 且 Phase 15 提交态引用未定义的 `reopenEpisode`,**整个模块从未编译通过**,"474 绿"是假象。已统一双实现到 +> `RelationshipService.confirmActivityInterest`,修复 7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度缺陷,新建 7 用例互选测试, +> 并经负向验证证明守卫有牙。报告:`docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md` + ### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过) | 任务 | 状态 | |------|------| diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index e5d476d..3991807 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -414,16 +414,43 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M > 报告:`docs/Phase-15-V11-Recovery-Completion-Report-2026-09-17.md` > **Phase 16 未自动进入,等待用户确认。** -### Phase 16:活动互选与双方开启沟通确认 — REFACTOR +### Phase 16:活动互选与双方开启沟通确认 — REFACTOR ✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验关闭) **Files:** activity_mutual、relationship、messaging 模块及 activity-mutual 页面。 -- [ ] 以 activity_user_interests + activity_mutual_connection_consents 为唯一逻辑,废弃 mutual 直接建 Match/Chat 的所有入口。 -- [ ] 双方确认事务中:已有 active Match 则复用已有会话;历史 Unmatch 则创建新 Episode;无历史则创建 Match 和 Conversation。 -- [ ] 补 decline、timeout、重复确认幂等、并发确认和单向意向保密测试。 +- [x] 以 activity_mutual_connection_consents 为唯一逻辑,双实现统一到 `RelationshipService.confirmActivityInterest`。 +- [x] 双方确认事务中:已有 active Match 则复用已有会话;历史 Unmatch 则复用同一行(episode 递增 + reopen);无历史则创建 Match 和 Conversation。 +- [x] 补 decline、expires_at 超时、重复确认幂等、并发确认(exactly-once)和单向意向保密(INV-006)测试。 **Gate:** A 单选 B 时 B 无感知;Mutual 但未双确认时无新 Chat;双确认后 exactly-once 形成正确 Match/Conversation。 +> **2026-09-17 复验结论(KEEP-VERIFY/EXTEND):** 历史 ✅ 不可信,逐条核对运行时路径后发现**活动互选核心 Gate 缺陷**—— +> 新路径 `ActivityMutualConsentController.confirmConnection` 双确认后**根本不建 Match/会话**(违反 INV-014/015),而旧路径 +> `ActivityInterest.confirmInterest`→`confirmActivityInterest` 建 Match 却**缺会话**;两条路径分裂。已统一到 +> `RelationshipService.confirmActivityInterest` 单实现。修复 **7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度缺陷**: +> - **D1(Critical,Gate 缺陷 · INV-014/015)** 双确认后不建 Match/会话 → 重写为 `confirmConnection` 走 +> `confirmActivityInterest`,由原子 `confirmSideIfPending` 保证 exactly-once(`both_confirmed_at` NULL→非NULL 仅一方赢家建 Match+会话并 `bindMatch` 溯源);单方确认不建 Match。 +> - **D2(High,双实现分裂)** 旧/新路径各自为政 → 统一到 `RelationshipService.confirmActivityInterest`(返回 `matchId` + `priorUnmatch`)。 +> - **D3(High,隐藏编译缺陷 · 必崩级)** Phase 15 提交态引用 `matchMapper.reopenEpisode` 但该方法**从未定义** → 整个 +> activity_mutual / relationship 模块**根本不编译**,**"474 绿"基线从未真实存在**。修复:在 `MatchMapper` 补 `@Update reopenEpisode` +> (`WHERE status='unmatched' AND deleted_at IS NULL`)。**这是本轮最严重发现:Phase 15 的提交态从未通过编译。** +> - **D4(High,INV-006 泄露)** `getMyConsentStatus` 本侧未确认即回显对方确认状态 → 改为 +> `partnerAccepted = myAccepted && partnerSideAccepted`(本侧未确认则对方确认态不回显)。 +> - **D5(Medium,缺会话)** 复用/新建 Match 后未显式开启会话 → `confirmActivityInterest` 现在**显式**调用 +> `chatService.getOrCreateConversation` 并返回 `conversationAutoCreated=true`。 +> - **D6(Medium,测试库零覆盖)** `schema-h2.sql` 缺 `activity_mutual_connection_consents` 表 → 补表 +> (含 `deleted_at`/`expires_at`/`match_id` 与 `chk_consent_status` CHECK、UNIQUE(activity_id,user_a_id,user_b_id)),与 V024+V037+V041 对齐; +> `EntityMigrationParityTest` 将其从 `TEST_SCHEMA_OPTIONAL_TABLES` 移入 `REQUIRED_IN_TEST_SCHEMA`;`MigrationValidationTest` 基线 40 → 41。 +> - **D7(Medium,无互选测试)** 新建 `ActivityMutualConsentServiceTest`(7 用例:双确认建 Match+会话+溯源、INV-006 单向不暴露、 +> decline 阻止、幂等重确认不重复建、expires_at 超时拒绝、软删过滤、复用 active Match),`@SpringBootTest` 走真实方法路径。 +> - **D8(Medium,断言与 #20 冲突)** `ChatPhase09IntegrationTest:344` 假设"每次活动互选新建独立会话",与 AGENTS.md #20 +> "同对用户 Unmatch 后重连复用同一 Match 行、会话随之复用"冲突 → 改为 `assertEquals`(认可复用),保留 sendText/WebSocket/未读主断言以验证"复用同一消息管线"。 +> - **负向验证**:临时注释 `createMatchAndConversation(consent)` 调用 → `ActivityMutualConsentServiceTest` 中 doubleConfirm + idempotent +> 两条依赖 Match 的用例**精准变红**(`Tests run: 7, Failures: 2`)→ 证明守卫有牙 → 已还原(`grep -c "NEG-VAL"` = 0)。 +> - 新增迁移 `V041__extend_mutual_consent_expiry.sql`(`expires_at` + `match_id` + 索引)。 +> - 全量回归 **481 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 5 分)。基线 474 → 481(净 +7)。 +> - 报告:`docs/Phase-16-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 17 需用户确认后启动。** + ### Phase 17:举报与风控 — EXTEND **Files:** risk、report、referral 模块和后台风险页面。