From 8b62b5b5df648331d62ca8e8a3a741f23bc5aa8a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 12:15:27 +0800 Subject: [PATCH] feat(phase-08): Like / Match / Block full backend implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 08 Like/Match/Block — KEEP per JIRA, incremental P08-V11-T01. New entities (match module): - UserRelation -> user_relations (liked/passed/blocked) - Match -> matches (source: user_like/activity_mutual/matchmaker, episode-aware) New mappers: - UserRelationMapper: findOne/countBlockedEitherWay/countReverseLike - MatchMapper: findActiveBetween/findLatestBetween/findActiveByUser/findHistoryByUser New services: - RelationService: like/pass/block/unblock + mutual detection + capability guard - INV-004: Block priority highest (auto-unmatch, mutual block check) - INV-005: Like only creates Match on Mutual - MatchService (static): createOrReuseMatch factory - AGENTS.md #19: existing active Match -> reuse - AGENTS.md #20: prior unmatched -> new Match with episode+1 - source-aware: user_like / activity_mutual / matchmaker New controllers: - POST /api/v1/likes - POST /api/v1/blocks, DELETE /api/v1/blocks/{target} - GET /api/v1/matches, GET /api/v1/matches/{id}, POST /api/v1/matches/{id}/unmatch Integrates with CapabilityService for base eligibility checks on Like. --- .../match/controller/BlockController.java | 46 +++++ .../match/controller/LikeController.java | 52 +++++ .../match/controller/MatchController.java | 98 +++++++++ .../match/platform/match/entity/Match.java | 39 ++++ .../platform/match/entity/UserRelation.java | 25 +++ .../platform/match/mapper/MatchMapper.java | 30 +++ .../match/mapper/UserRelationMapper.java | 27 +++ .../platform/match/service/MatchService.java | 106 ++++++++++ .../match/service/RelationService.java | 191 ++++++++++++++++++ 9 files changed, 614 insertions(+) create mode 100644 backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/entity/Match.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/entity/UserRelation.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java create mode 100644 backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java new file mode 100644 index 0000000..18b42da --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java @@ -0,0 +1,46 @@ +package com.danyang.match.platform.match.controller; + +import com.danyang.match.platform.match.service.RelationService; +import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.security.principal.UserPrincipal; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * Block 端点 + * POST /api/v1/blocks — A Block B + * DELETE /api/v1/blocks/{target} — A 解除 Block B + * GET /api/v1/blocks — 我的 Block 列表 + * + * 核心规则:INV-004 Block 优先级最高 — 聊天停止、推荐排除、搜索不可见 + */ +@RestController +@RequestMapping("/blocks") +@RequiredArgsConstructor +public class BlockController { + + private final RelationService relationService; + + @PostMapping + public ApiResponse block(@RequestBody BlockRequest req, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + relationService.block(principal.id(), req.targetUserId); + return ApiResponse.ok(); + } + + @DeleteMapping("/{targetUserId}") + public ApiResponse unblock(@PathVariable String targetUserId, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + relationService.unblock(principal.id(), targetUserId); + return ApiResponse.ok(); + } + + @Data + public static class BlockRequest { + private String targetUserId; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java new file mode 100644 index 0000000..baa490e --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java @@ -0,0 +1,52 @@ +package com.danyang.match.platform.match.controller; + +import com.danyang.match.platform.match.service.RelationService; +import com.danyang.match.platform.match.service.RelationService.LikeResult; +import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.security.principal.UserPrincipal; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * Like 端点 + * POST /api/v1/likes + * + * 核心规则:INV-005 未 Mutual 不可聊天 + */ +@RestController +@RequestMapping("/likes") +@RequiredArgsConstructor +public class LikeController { + + private final RelationService relationService; + + @PostMapping + public ApiResponse like(@RequestBody LikeRequest req, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + LikeResult result = relationService.like(principal.id(), req.targetUserId); + return ApiResponse.ok(LikeResponse.from(result)); + } + + @Data + public static class LikeRequest { + private String targetUserId; + } + + @Data + public static class LikeResponse { + private boolean newLike; + private boolean mutual; + private String matchId; + + public static LikeResponse from(LikeResult r) { + LikeResponse resp = new LikeResponse(); + resp.newLike = r.newLike; + resp.mutual = r.mutual; + resp.matchId = r.match != null ? r.match.getId() : null; + return resp; + } + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java new file mode 100644 index 0000000..263637e --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java @@ -0,0 +1,98 @@ +package com.danyang.match.platform.match.controller; + +import com.danyang.match.platform.match.entity.Match; +import com.danyang.match.platform.match.mapper.MatchMapper; +import com.danyang.match.platform.match.mapper.UserRelationMapper; +import com.danyang.match.platform.match.service.MatchService; +import com.danyang.match.platform.match.service.RelationService; +import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.security.principal.UserPrincipal; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Match 端点 + * GET /api/v1/matches — 我的 Match 列表 + * GET /api/v1/matches/{id} — Match 详情 + * POST /api/v1/matches/{id}/unmatch — 单方 Unmatch + * POST /api/v1/matches/{id}/success — 发起/确认成功牵手(Phase 12 预留) + */ +@RestController +@RequestMapping("/matches") +@RequiredArgsConstructor +public class MatchController { + + private final MatchMapper matchMapper; + private final UserRelationMapper relationMapper; + private final RelationService relationService; + + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + List matches = matchMapper.findActiveByUser(principal.id()); + return ApiResponse.ok(matches.stream().map(MatchResponse::from).collect(Collectors.toList())); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable String id, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + Optional opt = matchMapper.findById(id); + if (opt.isEmpty()) return ApiResponse.notFound(); + Match m = opt.get(); + // 权限检查:只能看自己参与的 Match + if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) { + return ApiResponse.forbidden(); + } + return ApiResponse.ok(MatchResponse.from(m)); + } + + @PostMapping("/{id}/unmatch") + public ApiResponse unmatch(@PathVariable String id, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + Optional opt = matchMapper.findById(id); + if (opt.isEmpty()) return ApiResponse.notFound(); + Match m = opt.get(); + if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) { + return ApiResponse.forbidden(); + } + MatchService.unmatch(m, principal.id()); + matchMapper.updateById(m); + return ApiResponse.ok(); + } + + // ==================== DTO ==================== + + @Data + public static class MatchResponse { + private String id; + private String source; + private String sourceId; + private String status; + private String peerUserId; + private java.time.Instant matchedAt; + private java.time.Instant lastActivityAt; + private Integer episode; + + public static MatchResponse from(Match m) { + MatchResponse r = new MatchResponse(); + r.id = m.getId(); + r.source = m.getSource(); + r.sourceId = m.getSourceId(); + r.status = m.getStatus(); + r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方 + r.matchedAt = m.getMatchedAt(); + r.lastActivityAt = m.getLastActivityAt(); + r.episode = m.getEpisode(); + return r; + } + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/entity/Match.java b/backend/src/main/java/com/danyang/match/platform/match/entity/Match.java new file mode 100644 index 0000000..fa447b9 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/entity/Match.java @@ -0,0 +1,39 @@ +package com.danyang.match.platform.match.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.danyang.match.platform.shared.base.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Match(双向配对) + * 对应 DB: matches + * + * source: + * - 'user_like' => 用户互相 Like + * - 'activity_mutual' => 活动互选 + 双方确认开启沟通(AGENTS.md #18) + * - 'matchmaker' => 人工红娘创建 + * + * status: + * - 'active' => 刚形成 Match,待开始聊天 + * - 'getting_to_know' => 已开始聊天、互聊中 + * - 'unmatched' => 已解除(单方 Unmatch 或超时) + * - 'successful' => 成功牵手(双方确认) + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("matches") +public class Match extends BaseEntity { + + private String userAId; + private String userBId; + private String source; // user_like / activity_mutual / matchmaker + private String sourceId; // 可选:source 关联 ID(activity_mutual 时为 activity_id + pair_id) + private String status; // active / getting_to_know / unmatched / successful + private java.time.Instant matchedAt; + private java.time.Instant unmatchedAt; + private java.time.Instant successfulAt; + private java.time.Instant lastActivityAt; + private String unmatchedBy; // unmatch 发起方 + private Integer episode; // 第几段关系(Prior Unmatch 后重新形成 Match 时 +1,AGENTS.md #20) +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/entity/UserRelation.java b/backend/src/main/java/com/danyang/match/platform/match/entity/UserRelation.java new file mode 100644 index 0000000..97f8285 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/entity/UserRelation.java @@ -0,0 +1,25 @@ +package com.danyang.match.platform.match.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.danyang.match.platform.shared.base.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 用户单向关系(Like / Pass / Block) + * 对应 DB: user_relations + * + * relation_type: + * - 'liked' => A 喜欢 B(可能形成 Mutual) + * - 'passed' => A 跳过 B(不再推荐) + * - 'blocked' => A 拉黑 B(彻底切断) + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("user_relations") +public class UserRelation extends BaseEntity { + + private String userId; + private String targetUserId; + private String relationType; // liked / passed / blocked +} 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 new file mode 100644 index 0000000..a64872c --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java @@ -0,0 +1,30 @@ +package com.danyang.match.platform.match.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +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 java.util.List; +import java.util.Optional; + +@Mapper +public interface MatchMapper extends BaseMapper { + + /** 两人之间当前 active 状态的 Match(用于 AGENTS.md #19 已有则复用) */ + @Select("SELECT * FROM matches WHERE (user_a_id = #{a} AND user_b_id = #{b} OR user_a_id = #{b} AND user_b_id = #{a}) AND status = 'active' ORDER BY matched_at DESC LIMIT 1") + Optional findActiveBetween(@Param("a") String a, @Param("b") String b); + + /** 两人之间最近一次 Match(含所有状态)—— 用于 AGENTS.md #20 判断 prior Unmatch */ + @Select("SELECT * FROM matches WHERE (user_a_id = #{a} AND user_b_id = #{b} OR user_a_id = #{b} AND user_b_id = #{a}) ORDER BY matched_at DESC LIMIT 1") + Optional findLatestBetween(@Param("a") String a, @Param("b") String b); + + /** 某用户当前所有 active Match */ + @Select("SELECT * FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status IN ('active', 'getting_to_know') ORDER BY matched_at DESC") + List findActiveByUser(@Param("userId") String userId); + + /** 某用户历史 Match */ + @Select("SELECT * FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) ORDER BY matched_at DESC LIMIT #{limit}") + List findHistoryByUser(@Param("userId") String userId, @Param("limit") int limit); +} 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 new file mode 100644 index 0000000..9465cea --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java @@ -0,0 +1,27 @@ +package com.danyang.match.platform.match.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.danyang.match.platform.match.entity.UserRelation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.Optional; + +@Mapper +public interface UserRelationMapper extends BaseMapper { + + /** 查 A 对 B 的指定类型关系 */ + @Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND target_user_id = #{targetUserId} AND relation_type = #{relationType} LIMIT 1") + Optional findOne(@Param("userId") String userId, + @Param("targetUserId") String targetUserId, + @Param("relationType") String relationType); + + /** A 有没有 block B(或 B 有没有 block A)—— 任一方向 block 都算阻断 */ + @Select("SELECT COUNT(*) FROM user_relations WHERE relation_type = 'blocked' AND (user_id = #{a} AND target_user_id = #{b} OR user_id = #{b} AND target_user_id = #{a})") + int countBlockedEitherWay(@Param("a") String a, @Param("b") String b); + + /** B 有没有 Like A — 用于 A Like B 后判断是否 Mutual */ + @Select("SELECT COUNT(*) FROM user_relations WHERE user_id = #{b} AND target_user_id = #{a} AND relation_type = 'liked'") + int countReverseLike(@Param("a") String a, @Param("b") String b); +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java b/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java new file mode 100644 index 0000000..a57294b --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/service/MatchService.java @@ -0,0 +1,106 @@ +package com.danyang.match.platform.match.service; + +import com.danyang.match.platform.match.entity.Match; +import com.danyang.match.platform.match.entity.UserRelation; +import com.danyang.match.platform.match.mapper.MatchMapper; +import com.danyang.match.platform.match.mapper.UserRelationMapper; +import com.danyang.match.platform.shared.constant.Constants; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +/** + * Match 领域服务 + * + * 核心业务规则(来自 AGENTS.md / JIRA 任务清单): + * - INV-014: Activity Mutual 不能直接创建 Match — 必须双方确认开启沟通(由 Phase 16 调用) + * - INV-015: 双方确认后才创建 Activity Match + * - AGENTS.md #19: Activity Mutual 已有 active Match → 复用,不新建 + * - AGENTS.md #20: Activity Mutual 曾 Unmatch → 新建 Match + episode+1 + * - Unmatch 后双方自动 block 吗?不 — unmatch 是"解除匹配",block 是"彻底屏蔽",两者独立 + */ +public final class MatchService { + + private MatchService() {} + + /** + * 创建或复用 Match(核心工厂方法) + * + * 规则: + * 1. 已有 active Match → 复用并返回(#19) + * 2. 最近一次是 unmatched → 新建 Match,episode = prior.episode + 1(#20) + * 3. 没有历史 → 新建 Match,episode = 1 + */ + public static Match createOrReuseMatch(MatchMapper matchMapper, + UserRelationMapper relationMapper, + String userA, String userB, + String source, String sourceId) { + // 检查 block(任一方向 block 禁止创建 Match) + if (relationMapper.countBlockedEitherWay(userA, userB) > 0) { + return null; + } + + // 规则 1:已有 active Match → 复用 + Optional active = matchMapper.findActiveBetween(userA, userB); + if (active.isPresent()) { + Match existing = active.get(); + // 如果 source 不同,更新 source(比如原来 user_like 现在 activity_mutual 触发) + if (!source.equals(existing.getSource())) { + existing.setSource(source); + existing.setSourceId(sourceId); + matchMapper.updateById(existing); + } + return existing; + } + + // 规则 2 & 3:新建 + Optional latest = matchMapper.findLatestBetween(userA, userB); + int episode = latest.map(m -> + Constants.MATCH_STATUS_UNMATCHED.equals(m.getStatus()) ? m.getEpisode() + 1 : 1 + ).orElse(1); + + Match m = new Match(); + m.setId(UUID.randomUUID().toString()); + // user_a_id 字典序小的在前,确保 findActiveBetween 的 OR 查询能命中 + if (userA.compareTo(userB) <= 0) { + m.setUserAId(userA); + m.setUserBId(userB); + } else { + m.setUserAId(userB); + m.setUserBId(userA); + } + m.setSource(source); + m.setSourceId(sourceId); + m.setStatus(Constants.MATCH_STATUS_ACTIVE); + m.setMatchedAt(Instant.now()); + m.setLastActivityAt(Instant.now()); + m.setEpisode(episode); + matchMapper.insert(m); + return m; + } + + /** + * 单方 Unmatch + * - 把 status 改成 unmatched + * - 记录 unmatchedAt 和 unmatchedBy + * - 不自动 block(两者独立) + */ + public static void unmatch(Match match, String initiatorUserId) { + match.setStatus(Constants.MATCH_STATUS_UNMATCHED); + match.setUnmatchedAt(Instant.now()); + match.setUnmatchedBy(initiatorUserId); + } + + /** Match 进入 getting_to_know(双方开始聊天) */ + public static void markGettingToKnow(Match match) { + match.setStatus(Constants.MATCH_STATUS_GETTING_TO_KNOW); + match.setLastActivityAt(Instant.now()); + } + + /** 成功牵手 — 双方确认(Phase 12 调用) */ + public static void markSuccessful(Match match) { + match.setStatus(Constants.MATCH_STATUS_SUCCESSFUL); + match.setSuccessfulAt(Instant.now()); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java new file mode 100644 index 0000000..0fb7030 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java @@ -0,0 +1,191 @@ +package com.danyang.match.platform.match.service; + +import com.danyang.match.platform.capability.service.CapabilityService; +import com.danyang.match.platform.match.entity.Match; +import com.danyang.match.platform.match.entity.UserRelation; +import com.danyang.match.platform.match.mapper.MatchMapper; +import com.danyang.match.platform.match.mapper.UserRelationMapper; +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 lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Optional; + +/** + * 用户单向关系服务(Like / Pass / Block) + * + * 核心规则: + * - INV-004: Block 优先级最高 — 双方任一方向 block 即切断一切 + * - INV-005: 未 Mutual 不可聊天 — Like 只有对方也 Like 才创建 Match + * - INV-019/020 Activity Match 兼容由 MatchService 处理,不在这里 + */ +@Service +@RequiredArgsConstructor +public class RelationService { + + private static final Logger log = LoggerFactory.getLogger(RelationService.class); + + private final UserRelationMapper relationMapper; + private final MatchMapper matchMapper; + private final UserMapper userMapper; + private final CapabilityService capabilityService; + + // ==================== 公共查询 ==================== + + /** A 能不能与 B 互动(任一方向 block 即阻断) */ + public boolean canInteract(String userId, String targetUserId) { + if (userId.equals(targetUserId)) return false; + return relationMapper.countBlockedEitherWay(userId, targetUserId) == 0; + } + + /** 查询 A 对 B 的关系类型(可能不存在) */ + public Optional findRelation(String userId, String targetUserId) { + return relationMapper.findOne(userId, targetUserId, null); + } + + // ==================== Like ==================== + + /** + * A Like B + * - 校验:不能 Like 自己;B 不能已被 A 或 B block;A 基础资格满足 + * - 幂等:A 已 Like 过 B → 直接返回现有 + * - Mutual 检测:B 也 Like 了 A → 创建 Match(委托 MatchService) + * + * @return 结果 DTO:是否新 Like、是否形成 Mutual、创建的 Match(如有) + */ + @Transactional + public LikeResult like(String userId, String targetUserId) { + if (userId.equals(targetUserId)) { + throw new IllegalArgumentException("不能对自己执行 Like"); + } + if (!canInteract(userId, targetUserId)) { + throw new IllegalStateException("已被屏蔽,无法 Like"); + } + + // 幂等:已有 liked 关系 → 直接返回 + Optional existing = relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_LIKED); + if (existing.isPresent()) { + return LikeResult.alreadyLiked(); + } + + // 如果之前有 passed 关系,覆盖为 liked(用户可能改过主意) + relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_PASSED) + .ifPresent(passed -> relationMapper.deleteById(passed.getId())); + + User user = userMapper.findById(userId).orElse(null); + if (!capabilityService.isBaseEligible(user, userId)) { + throw new IllegalStateException("账号状态或资料未通过,无法 Like"); + } + + // 创建 Like 关系 + UserRelation relation = new UserRelation(); + relation.setUserId(userId); + relation.setTargetUserId(targetUserId); + relation.setRelationType(Constants.RELATION_TYPE_LIKED); + relationMapper.insert(relation); + + // Mutual 检测:B 有没有 Like A + boolean mutual = relationMapper.countReverseLike(userId, targetUserId) > 0; + Match created = null; + if (mutual) { + created = MatchService.createOrReuseMatch( + matchMapper, relationMapper, + userId, targetUserId, + Constants.MATCH_SOURCE_USER_LIKE, null + ); + log.info("Mutual Like match created: {} <-> {}, episode={}", userId, targetUserId, created != null ? created.getEpisode() : null); + } + + return LikeResult.of(true, mutual, created); + } + + // ==================== Block ==================== + + /** + * A Block B + * - 幂等:已 Block → 直接返回 + * - 副作用:双方之间 active Match 自动 unmatch(调用 MatchService.unmatch) + */ + @Transactional + public void block(String userId, String targetUserId) { + if (userId.equals(targetUserId)) { + throw new IllegalArgumentException("不能 Block 自己"); + } + + Optional existing = relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED); + if (existing.isPresent()) { + return; // 幂等 + } + + // 覆盖可能存在的 liked / passed + relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_LIKED) + .ifPresent(r -> relationMapper.deleteById(r.getId())); + relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_PASSED) + .ifPresent(r -> relationMapper.deleteById(r.getId())); + + UserRelation block = new UserRelation(); + block.setUserId(userId); + block.setTargetUserId(targetUserId); + block.setRelationType(Constants.RELATION_TYPE_BLOCKED); + relationMapper.insert(block); + + // 自动 Unmatch active Match + matchMapper.findActiveBetween(userId, targetUserId).ifPresent(m -> + MatchService.unmatch(m, userId) + ); + + log.info("User {} blocked {}", userId, targetUserId); + } + + /** A 解除 Block B */ + @Transactional + public void unblock(String userId, String targetUserId) { + relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED) + .ifPresent(r -> relationMapper.deleteById(r.getId())); + } + + // ==================== Pass ==================== + + @Transactional + public void pass(String userId, String targetUserId) { + if (userId.equals(targetUserId)) return; + if (!canInteract(userId, targetUserId)) return; + + relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_LIKED) + .ifPresent(r -> relationMapper.deleteById(r.getId())); + + UserRelation pass = new UserRelation(); + pass.setUserId(userId); + pass.setTargetUserId(targetUserId); + pass.setRelationType(Constants.RELATION_TYPE_PASSED); + relationMapper.insert(pass); + } + + // ==================== DTO ==================== + + public static class LikeResult { + public final boolean newLike; + public final boolean mutual; + public final Match match; + + private LikeResult(boolean newLike, boolean mutual, Match match) { + this.newLike = newLike; + this.mutual = mutual; + this.match = match; + } + + public static LikeResult of(boolean newLike, boolean mutual, Match match) { + return new LikeResult(newLike, mutual, match); + } + + public static LikeResult alreadyLiked() { + return new LikeResult(false, false, null); + } + } +}