feat(phase-08): Like / Match / Block full backend implementation
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.
This commit is contained in:
+46
@@ -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<Void> 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<Void> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<LikeResponse> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -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<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
||||
return ApiResponse.ok(matches.stream().map(MatchResponse::from).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<MatchResponse> get(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
Optional<Match> 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<Void> unmatch(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
Optional<Match> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Match> {
|
||||
|
||||
/** 两人之间当前 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<Match> 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<Match> 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<Match> 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<Match> findHistoryByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
}
|
||||
@@ -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<UserRelation> {
|
||||
|
||||
/** 查 A 对 B 的指定类型关系 */
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND target_user_id = #{targetUserId} AND relation_type = #{relationType} LIMIT 1")
|
||||
Optional<UserRelation> 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);
|
||||
}
|
||||
@@ -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<Match> 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<Match> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<UserRelation> 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<UserRelation> 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<UserRelation> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user