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:
Codex
2026-09-14 12:15:27 +08:00
parent 0da82c9914
commit 8b62b5b5df
9 changed files with 614 additions and 0 deletions
@@ -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;
}
}
}
@@ -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);
}
}
}