refactor(api): 关系/匹配域采用 2026-09-14 canonical 路径,消除三代并存

## 问题:同一能力存在三代路径,前端指向的是已废止的一代

按 mtime 排查后发现"最新"并非上一轮以为的 /relations/*,而是 2026-09-14 的 match 模块:

| 代际 | 位置 | 日期 | 路径 |
|---|---|---|---|
| canonical | platform.match: Like/Block/MatchController | 09-14 12:13~12:14 | /likes /blocks /matches |
| 二代 | platform.relationship: Relationship(Status)Controller | 09-10 10:59 | /relations/* /relationship/* |
| 一代(规范) | REST API 接口详细设计规范.md | 08-28 | /relationships/*(多一个 hips) |
| 前端 | miniapp/services/matchService.js | 08-29 | 混用二代 + 一代 |

后果:缘分三榜、黑名单页、成功牵手流程全部 404;另有两份重复 success 实现 + 一份桩实现。

## 裁决:采用 2026-09-14 最新路径,前端与二代让位(用户指令)

### 补齐 canonical 模块缺口(此前只有 javadoc 声明或服务方法,无控制器)
- RelationService.unlike() + LikeController `DELETE /likes/{targetUserId}`
- 新增 PassController `POST /passes`(RelationService.pass 在 09-14 模块内却无控制器,补齐与 /likes、/blocks 对称)
- BlockController `GET /blocks`(javadoc 早声明"我的 Block 列表"但未实现,mapper findBlockedByUser 已就绪)
- MatchController `POST /matches/{id}/success/request|confirm|decline`(javadoc 标"Phase 12 预留"),
  委派真实实现 RelationshipStatusService;authorizedMatch 对"不存在"与"无权"统一回 404,避免探测他人 Match
- LikeResponse 对齐前端 LikeResult:{ relationStatus, mutualMatch, matchedConversationId, matchId }
  Mutual 时调 ChatService.getOrCreateConversation 取会话 id → "双向喜欢→跳聊天"链路真正打通
- LikeController/PassController/BlockController 补 targetUserId 空值校验

### 移除被取代的路由与桩实现
- RelationshipController 移除 like/cancelLike/pass/block/unblock/matches/unmatch/success,仅保留 /fate/*
- RelationshipStatusController 移除 /success/*(已由 /matches/{id}/success/* 取代),保留关系状态生命周期
- RelationshipService 删除 requestSuccess/confirmSuccess 桩 ——
  前者自述 "TODO: 查询 relationship_success_requests 表" 却返回硬编码 status="pending",
  等于绕过 AGENTS 硬规则 6「成功牵手必须双确认」
- 注意:RelationshipService.like/block/pass 被 P0FixVerificationTest /
  RelationshipBlockPriorityPhase08Test 直接测试,方法体保留

### 列表信封统一
- CursorPage 从 chat.dto 提升到 shared.dto(消除 match 模块反向依赖 chat DTO)
- /relations/fate/mutual|liked|likers 与 GET /matches 改返回 { items, nextCursor }

### 前端对齐
- matchService.js 全部路径改到 canonical;补 declineSuccess
- matchStore 补 _decorateBlockedList,修复"模板渲染 blockedAtText 但全仓从未计算"的缺陷
  (与上一轮 lastMessageAtText 同源,同类字段一律在 store 装饰一次)

### 文档
- docs/api/API_INVENTORY.md 关系/缘分/匹配三节重写为 canonical 路径;
  顶部加 /me/* 路径族显式警告(小程序按规范写 /me/*,后端实为 /profile/*、/photos、/relationship/*,
  约 15 个端点会 404,收敛方向未裁决,防止后续照抄踩坑)
- docs/STATUS.md:Backend 回归债务由「待清(277 tests 4F/8E)」更新为「已清偿(297 tests 全绿)」

## 验证
- 新增 CanonicalRouteContractTest(3 例):用 RequestMappingHandlerMapping 断言
  canonical 路径已注册、被取代的旧路径不再注册、关系状态生命周期保留 —— 路由再漂移即红
- 后端 297 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
- 小程序 tsc --noEmit 通过
This commit is contained in:
Codex
2026-09-15 23:16:27 +08:00
parent 687a156350
commit 3244e02e7e
19 changed files with 636 additions and 333 deletions
@@ -1,7 +1,7 @@
package com.danyang.match.platform.chat.controller;
import com.danyang.match.platform.chat.dto.ConversationDTO;
import com.danyang.match.platform.chat.dto.CursorPage;
import com.danyang.match.platform.shared.dto.CursorPage;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.entity.Match;
@@ -1,6 +1,6 @@
package com.danyang.match.platform.chat.controller;
import com.danyang.match.platform.chat.dto.CursorPage;
import com.danyang.match.platform.shared.dto.CursorPage;
import com.danyang.match.platform.chat.entity.Message;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.shared.dto.ApiResponse;
@@ -1,22 +0,0 @@
package com.danyang.match.platform.chat.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/**
* 游标分页响应
* 对齐小程序期望的 { items, nextCursor } 结构。
*/
@Data
@AllArgsConstructor
public class CursorPage<T> {
private List<T> items;
private String nextCursor;
public static <T> CursorPage<T> of(List<T> items, String nextCursor) {
return new CursorPage<>(items, nextCursor);
}
}
@@ -1,32 +1,51 @@
package com.danyang.match.platform.match.controller;
import com.danyang.match.platform.match.entity.UserRelation;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.dto.CursorPage;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
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.stream.Collectors;
/**
* Block 端点
* POST /api/v1/blocks — A Block B
* DELETE /api/v1/blocks/{target} — A 解除 Block B
* GET /api/v1/blocks — 我的 Block 列表
* Block 端点(canonical 路径,2026-09-14 版)
*
* POST /api/v1/blocks — A Block B(targetUserId 在 body)
* DELETE /api/v1/blocks/{targetUserId} — A 解除 Block B
* GET /api/v1/blocks — 我的 Block 列表(黑名单页)
*
* 核心规则:INV-004 Block 优先级最高 — 聊天停止、推荐排除、搜索不可见
*
* 说明:原 /relations/{targetUserId}/block(2026-09-10 版)已被本控制器取代。
*/
@RestController
@RequestMapping("/blocks")
@RequiredArgsConstructor
public class BlockController {
/** 列表分页上限,防止客户端一次拉爆 */
private static final int MAX_PAGE_SIZE = 100;
private final RelationService relationService;
private final UserRelationMapper relationMapper;
private final UserProfileMapper userProfileMapper;
@PostMapping
public ApiResponse<Void> block(@RequestBody BlockRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
relationService.block(principal.id(), req.targetUserId);
return ApiResponse.ok();
}
@@ -39,8 +58,72 @@ public class BlockController {
return ApiResponse.ok();
}
/**
* 我的 Block 列表(黑名单管理页)
*
* 返回字段脱敏:仅 userId / 昵称 / 拉黑时间,不含手机号、身份证等敏感信息
* (AGENTS 硬规则 8:敏感数据默认拒绝)。
*
* 游标为 offset 的不透明字符串,客户端只做透传;未到末页时 nextCursor 非空。
*/
@GetMapping
public ApiResponse<CursorPage<BlockedUserResponse>> list(
@RequestParam(required = false) String cursor,
@RequestParam(required = false, defaultValue = "20") int pageSize,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
List<UserRelation> all = relationMapper.findBlockedByUser(principal.id());
int offset = parseOffset(cursor);
int size = Math.max(1, Math.min(pageSize, MAX_PAGE_SIZE));
List<BlockedUserResponse> items = all.stream()
.skip(offset)
.limit(size)
.map(r -> BlockedUserResponse.from(r, userProfileMapper))
.collect(Collectors.toList());
int consumed = offset + items.size();
String nextCursor = consumed < all.size() ? String.valueOf(consumed) : null;
return ApiResponse.ok(CursorPage.of(items, nextCursor));
}
/** 游标非法时按首页处理,不抛错(游标对客户端不透明,容错优先) */
private int parseOffset(String cursor) {
if (cursor == null || cursor.isBlank()) return 0;
try {
return Math.max(0, Integer.parseInt(cursor.trim()));
} catch (NumberFormatException e) {
return 0;
}
}
@Data
public static class BlockRequest {
private String targetUserId;
}
/** 黑名单列表项(对齐 miniapp/pages/blacklist 渲染字段) */
@Data
public static class BlockedUserResponse {
private String userId;
private String userName;
/** 平台尚未实现头像 URL 解析(user_photos 只存 fileKey),暂为 null,前端回退昵称首字 */
private String avatarUrl;
/** epoch 毫秒,与全平台时间字段约定一致 */
private Long blockedAt;
private String reason;
public static BlockedUserResponse from(UserRelation relation, UserProfileMapper profileMapper) {
BlockedUserResponse r = new BlockedUserResponse();
r.userId = relation.getTargetUserId();
r.userName = profileMapper.findByUserId(relation.getTargetUserId())
.map(p -> p.getDisplayName())
.orElse(null);
r.avatarUrl = null;
r.blockedAt = relation.getCreatedAt() == null ? null : relation.getCreatedAt().toEpochMilli();
r.reason = null; // user_relations 未持久化拉黑原因,暂不返回
return r;
}
}
}
@@ -1,50 +1,102 @@
package com.danyang.match.platform.match.controller;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.match.service.RelationService.LikeResult;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/**
* Like 端点
* POST /api/v1/likes
* Like 端点(canonical 路径,2026-09-14 版)
*
* POST /api/v1/likes — A Like B
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
*
* 核心规则:INV-005 未 Mutual 不可聊天
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId }
*
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
*/
@Slf4j
@RestController
@RequestMapping("/likes")
@RequiredArgsConstructor
public class LikeController {
private final RelationService relationService;
private final ChatService chatService;
@PostMapping
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
LikeResult result = relationService.like(principal.id(), req.targetUserId);
return ApiResponse.ok(LikeResponse.from(result));
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId)));
}
/**
* 取消 Like
* 幂等;若已 Mutual(存在 active Match)则拒绝,必须先 Unmatch。
*/
@DeleteMapping("/{targetUserId}")
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
relationService.unlike(principal.id(), targetUserId);
return ApiResponse.ok();
}
/**
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
*
* - getOrCreateConversation 幂等,不会重复建会话
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
*/
private String ensureConversation(LikeResult result, String userId, String targetUserId) {
if (!result.mutual || result.match == null) return null;
try {
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
return conv.getId();
} catch (RuntimeException e) {
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
userId, targetUserId, result.match.getId(), e.getMessage());
return null;
}
}
// ==================== DTO ====================
@Data
public static class LikeRequest {
private String targetUserId;
}
/** 对齐 miniapp/types/index.d.ts#LikeResult */
@Data
public static class LikeResponse {
private boolean newLike;
private boolean mutual;
private String relationStatus;
private boolean mutualMatch;
private String matchedConversationId;
private String matchId;
public static LikeResponse from(LikeResult r) {
public static LikeResponse from(LikeResult r, String matchedConversationId) {
LikeResponse resp = new LikeResponse();
resp.newLike = r.newLike;
resp.mutual = r.mutual;
resp.relationStatus = r.mutual
? Constants.RELATION_STATUS_MUTUAL_MATCH
: Constants.RELATION_STATUS_LIKED;
resp.mutualMatch = r.mutual;
resp.matchedConversationId = matchedConversationId;
resp.matchId = r.match != null ? r.match.getId() : null;
return resp;
}
@@ -5,7 +5,9 @@ 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.relationship.service.RelationshipStatusService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.dto.CursorPage;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data;
import lombok.RequiredArgsConstructor;
@@ -13,16 +15,23 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
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 预留)
* Match 端点(canonical 路径,2026-09-14 版)
* GET /api/v1/matches — 我的 Match 列表
* GET /api/v1/matches/{id} — Match 详情
* POST /api/v1/matches/{id}/unmatch — 单方 Unmatch
* POST /api/v1/matches/{id}/success/request — 发起成功牵手
* POST /api/v1/matches/{id}/success/confirm — 确认成功牵手(双确认第二步)
* POST /api/v1/matches/{id}/success/decline — 拒绝成功牵手
*
* 成功牵手(AGENTS 硬规则 6:必须双确认)的真实状态机在 RelationshipStatusService,
* 本控制器只做"matchId → 对方 userId"的解析与成员权限校验后委派,
* 以取代 2026-09-10 的 /relations/success/* 桩实现与 /relationship/success/* 重复路由。
*/
@RestController
@RequestMapping("/matches")
@@ -32,12 +41,15 @@ public class MatchController {
private final MatchMapper matchMapper;
private final UserRelationMapper relationMapper;
private final RelationService relationService;
private final RelationshipStatusService relationshipStatusService;
@GetMapping
public ApiResponse<List<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
public ApiResponse<CursorPage<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()));
List<MatchResponse> items = matches.stream().map(MatchResponse::from).collect(Collectors.toList());
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
return ApiResponse.ok(CursorPage.all(items));
}
@GetMapping("/{id}")
@@ -69,6 +81,67 @@ public class MatchController {
return ApiResponse.ok();
}
// ==================== 成功牵手(AGENTS 硬规则 6:必须双确认) ====================
/**
* 发起成功牵手请求(双确认第一步)
* 真实状态机在 RelationshipStatusService:写入 relationship_success_requests,
* 已有 pending 复用、已 confirmed/declined 拒绝重复发起。
*/
@PostMapping("/{id}/success/request")
public ApiResponse<Map<String, Object>> requestSuccess(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
Match m = authorizedMatch(id, principal);
if (m == null) return ApiResponse.notFound();
return ApiResponse.ok(relationshipStatusService.requestSuccess(principal.id(), peerOf(m, principal.id())));
}
/**
* 确认成功牵手(双确认第二步,仅被请求方有效)
* 双方都确认后 Match 置 successful,双方关系状态置 matched_successfully。
*/
@PostMapping("/{id}/success/confirm")
public ApiResponse<Map<String, Object>> confirmSuccess(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
Match m = authorizedMatch(id, principal);
if (m == null) return ApiResponse.notFound();
return ApiResponse.ok(relationshipStatusService.confirmSuccess(principal.id(), peerOf(m, principal.id())));
}
/** 拒绝成功牵手 */
@PostMapping("/{id}/success/decline")
public ApiResponse<Void> declineSuccess(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
Match m = authorizedMatch(id, principal);
if (m == null) return ApiResponse.notFound();
relationshipStatusService.declineSuccess(principal.id(), peerOf(m, principal.id()));
return ApiResponse.ok();
}
/**
* 加载 Match 并校验当前用户是成员。
*
* 返回 null 时调用方统一回 404(而非 403)——不区分"不存在"与"无权访问",
* 避免通过错误码探测他人 Match 是否存在。
*/
private Match authorizedMatch(String id, UserPrincipal principal) {
Optional<Match> opt = matchMapper.findById(id);
if (opt.isEmpty()) return null;
Match m = opt.get();
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
return null;
}
return m;
}
/** Match 中的对方 userId */
private String peerOf(Match m, String userId) {
return userId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
}
// ==================== DTO ====================
@Data
@@ -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.enums.ApiResponseCode;
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.*;
/**
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
*
* POST /api/v1/passes — A 表示对 B 不合适
*
* 定位说明:
* RelationService.pass 属于 2026-09-14 的 match 模块,但此前没有控制器暴露,
* 前端只能走 2026-09-10 的 /relations/{targetUserId}/pass。为让 like/pass/block
* 三个动作落在同一模块(/likes、/passes、/blocks),补上本控制器,与 /likes、/blocks 对称。
*
* 单向意向保密(AGENTS 第 6 条):Pass 不通知对方,也不泄露给第三方。
*/
@RestController
@RequestMapping("/passes")
@RequiredArgsConstructor
public class PassController {
private final RelationService relationService;
@PostMapping
public ApiResponse<Void> pass(@RequestBody PassRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
relationService.pass(principal.id(), req.targetUserId);
return ApiResponse.ok();
}
@Data
public static class PassRequest {
private String targetUserId;
}
}
@@ -34,8 +34,8 @@ public interface UserRelationMapper extends BaseMapper<UserRelation> {
@Param("targetUserId") String targetUserId,
@Param("relationType") String relationType);
/** 我拉黑的人 */
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL")
/** 我拉黑的人(按拉黑时间倒序,供 GET /blocks 做确定性分页) */
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL ORDER BY created_at DESC, id DESC")
List<UserRelation> findBlockedByUser(String userId);
/** 喜欢我的人 */
@@ -105,6 +105,26 @@ public class RelationService {
return LikeResult.of(true, mutual, created);
}
/**
* A 取消 Like B
* - 幂等:没有 liked 关系 → 直接返回
* - Mutual 已成立(存在 active Match)→ 禁止单方取消,必须先走 Unmatch
* (否则会出现"Match 还在、Like 已撤"的不一致状态)
*/
@Transactional
public void unlike(String userId, String targetUserId) {
Optional<UserRelation> liked =
relationMapper.findOne(userId, targetUserId, Constants.RELATION_TYPE_LIKED);
if (liked.isEmpty()) {
return; // 幂等
}
if (matchMapper.findActiveBetween(userId, targetUserId).isPresent()) {
throw new IllegalStateException("已建立联络,无法取消喜欢,请先解除匹配");
}
relationMapper.deleteById(liked.get().getId());
log.info("User {} unliked {}", userId, targetUserId);
}
// ==================== Block ====================
/**
@@ -1,19 +1,29 @@
package com.danyang.match.platform.relationship.controller;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.relationship.dto.FateItemDTO;
import com.danyang.match.platform.relationship.dto.MatchDTO;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.dto.CursorPage;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 关系控制器
* 「我的缘分」控制器
*
* GET /api/v1/relations/fate/mutual — 互相喜欢
* GET /api/v1/relations/fate/liked — 我喜欢的
* GET /api/v1/relations/fate/likers — 喜欢我的(仅脱敏字段)
*
* 路径收敛说明(2026-09-15):
* 本控制器原本还承载 like / cancelLike / pass / block / unblock / matches / unmatch / success,
* 这些能力已由 2026-09-14 的 match 模块以 canonical 路径提供(/likes、/passes、/blocks、/matches),
* 且 /relations/success/* 是带 TODO 的桩实现,故一并移除,此处只保留没有更新归属的「缘分」查询。
* 对应服务方法仍被 P0FixVerificationTest / RelationshipBlockPriorityPhase08Test 直接测试,故 RelationshipService 本体保留。
*
* 列表形状统一为 { items, nextCursor }(全平台列表接口约定);三榜当前一次性返回全部,
* nextCursor 恒为 null,前端据此置 hasMore=false,后续加分页不必改契约。
*/
@RestController
@RequestMapping("/relations")
@@ -22,147 +32,24 @@ public class RelationshipController {
private final RelationshipService relationshipService;
/**
* 表达喜欢
* POST /api/v1/relations/{targetUserId}/like
*/
@PostMapping("/{targetUserId}/like")
public ApiResponse<Map<String, Object>> like(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
try {
return ApiResponse.ok(relationshipService.like(userId, targetUserId));
} catch (RuntimeException e) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT, e.getMessage());
}
}
/**
* 取消喜欢
* DELETE /api/v1/relations/{targetUserId}/like
*/
@DeleteMapping("/{targetUserId}/like")
public ApiResponse<Void> cancelLike(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
relationshipService.cancelLike(userId, targetUserId);
return ApiResponse.ok();
}
/**
* 表示不合适
* POST /api/v1/relations/{targetUserId}/pass
*/
@PostMapping("/{targetUserId}/pass")
public ApiResponse<Void> pass(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
relationshipService.pass(userId, targetUserId);
return ApiResponse.ok();
}
/**
* 拉黑
* POST /api/v1/relations/{targetUserId}/block
*/
@PostMapping("/{targetUserId}/block")
public ApiResponse<Void> block(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
relationshipService.block(userId, targetUserId);
return ApiResponse.ok();
}
/**
* 解除拉黑
* DELETE /api/v1/relations/{targetUserId}/block
*/
@DeleteMapping("/{targetUserId}/block")
public ApiResponse<Void> unblock(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
relationshipService.unblock(userId, targetUserId);
return ApiResponse.ok();
}
/**
* 获取我的匹配列表
* GET /api/v1/relations/matches
*/
@GetMapping("/matches")
public ApiResponse<List<MatchDTO>> getMyMatches(@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(relationshipService.getMyMatches(userId));
}
/**
* 解除匹配
* POST /api/v1/relations/matches/{targetUserId}/unmatch
*/
@PostMapping("/matches/{targetUserId}/unmatch")
public ApiResponse<Void> unmatch(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
relationshipService.unmatch(userId, targetUserId);
return ApiResponse.ok();
}
/**
* 我的缘分 - 互相喜欢
* GET /api/v1/relations/fate/mutual
*/
/** 互相喜欢(Mutual Match) */
@GetMapping("/fate/mutual")
public ApiResponse<List<FateItemDTO>> getMutual(@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(relationshipService.getMutualList(userId));
public ApiResponse<CursorPage<FateItemDTO>> getMutual(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(CursorPage.all(relationshipService.getMutualList(principal.id())));
}
/**
* 我的缘分 - 我喜欢的
* GET /api/v1/relations/fate/liked
*/
/** 我喜欢的 */
@GetMapping("/fate/liked")
public ApiResponse<List<FateItemDTO>> getLiked(@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(relationshipService.getLikedList(userId));
public ApiResponse<CursorPage<FateItemDTO>> getLiked(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(CursorPage.all(relationshipService.getLikedList(principal.id())));
}
/**
* 我的缘分 - 喜欢我的
* GET /api/v1/relations/fate/likers
*/
/** 喜欢我的(单向意向保密:仅返回脱敏字段,不泄露对方明示身份) */
@GetMapping("/fate/likers")
public ApiResponse<List<FateItemDTO>> getLikers(@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(relationshipService.getLikersList(userId));
public ApiResponse<CursorPage<FateItemDTO>> getLikers(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(CursorPage.all(relationshipService.getLikersList(principal.id())));
}
/**
* 发起成功牵手请求
* POST /api/v1/relations/success/request
*/
@PostMapping("/success/request")
public ApiResponse<Map<String, Object>> requestSuccess(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String userId = principal.id();
String targetUserId = body.get("targetUserId");
return ApiResponse.ok(relationshipService.requestSuccess(userId, targetUserId));
}
/**
* 确认成功牵手
* POST /api/v1/relations/success/confirm
*/
@PostMapping("/success/confirm")
public ApiResponse<Void> confirmSuccess(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String userId = principal.id();
String targetUserId = body.get("targetUserId");
relationshipService.confirmSuccess(userId, targetUserId);
return ApiResponse.ok();
}
}
@@ -72,54 +72,17 @@ public class RelationshipStatusController {
return ApiResponse.ok();
}
/**
* 发起成功牵手请求
* POST /api/v1/relationship/success/request
* Body: { "targetUserId": "..." }
/*
* 说明(2026-09-15):此处原有的 /success/request|confirm|decline 三个端点已移除。
*
* 原因:成功牵手是 Match 维度(一次牵手对应一个 Match Episode)的动作,
* canonical 路径已统一为 POST /api/v1/matches/{id}/success/request|confirm|decline
* (见 MatchController),由 matchId 解析对方并委派 RelationshipStatusService。
* 保留本控制器只负责「关系状态」生命周期本身(status / pause / resume / getting-to-know / exit)。
*
* 注意:RelationshipStatusService 的 requestSuccess / confirmSuccess / declineSuccess 方法本身保留,
* 它们是真实实现,且被 P0FixVerificationTest 直接调用测试。
*/
@PostMapping("/success/request")
public ApiResponse<Map<String, Object>> requestSuccess(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String userId = principal.id();
String targetUserId = body.get("targetUserId");
if (targetUserId == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
return ApiResponse.ok(relationshipStatusService.requestSuccess(userId, targetUserId));
}
/**
* 确认成功牵手
* POST /api/v1/relationship/success/confirm
* Body: { "targetUserId": "..." }
*/
@PostMapping("/success/confirm")
public ApiResponse<Map<String, Object>> confirmSuccess(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String userId = principal.id();
String targetUserId = body.get("targetUserId");
if (targetUserId == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
return ApiResponse.ok(relationshipStatusService.confirmSuccess(userId, targetUserId));
}
/**
* 拒绝成功牵手
* POST /api/v1/relationship/success/decline
* Body: { "targetUserId": "..." }
*/
@PostMapping("/success/decline")
public ApiResponse<Void> declineSuccess(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String userId = principal.id();
String targetUserId = body.get("targetUserId");
relationshipStatusService.declineSuccess(userId, targetUserId);
return ApiResponse.ok();
}
/**
* 退出服务
@@ -1,7 +1,6 @@
package com.danyang.match.platform.relationship.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
@@ -15,7 +14,6 @@ import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.chat.service.ChatService;
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;
@@ -341,44 +339,17 @@ public class RelationshipService {
// ==================== 成功牵手 ====================
/**
* 发起成功牵手请求
/*
* 说明(2026-09-15):此处原有的 requestSuccess / confirmSuccess 已删除。
*
* 原因:它们是未完成的桩实现 —— requestSuccess 里写着 "TODO: 查询 relationship_success_requests 表"
* 并直接返回硬编码 status="pending",confirmSuccess 也标注 "TODO: 实现双确认逻辑",
* 二者都不读写 relationship_success_requests 表,等于绕过了「成功牵手必须双确认」这条硬规则。
*
* 真实且完整的状态机实现在 RelationshipStatusService(读写 relationship_success_requests,
* 处理 pending / confirmed / declined 与重复发起),canonical 路径为
* POST /api/v1/matches/{id}/success/request|confirm|decline。
*/
@Transactional
public Map<String, Object> requestSuccess(String userId, String targetUserId) {
Match match = matchMapper.findActiveMatch(userId, targetUserId)
.orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系"));
// 检查是否已有pending的请求
// TODO: 查询 relationship_success_requests 表
Map<String, Object> result = new HashMap<>();
result.put("matchId", match.getId());
result.put("initiatedBy", userId);
result.put("status", "pending");
return result;
}
/**
* 确认成功牵手
*/
@Transactional
public void confirmSuccess(String userId, String targetUserId) {
// 必须由双方确认
// TODO: 实现双确认逻辑
Match match = matchMapper.findActiveMatch(userId, targetUserId)
.orElseThrow(() -> new RuntimeException("匹配不存在"));
match.setStatus(Constants.MATCH_STATUS_SUCCESSFUL);
match.setSuccessfulAt(Instant.now());
matchMapper.updateById(match);
// 更新用户关系状态
updateUserRelationshipStatus(userId, Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY);
updateUserRelationshipStatus(targetUserId, Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY);
log.info("成功牵手确认: userId={}, targetUserId={}", userId, targetUserId);
}
// ==================== 辅助方法 ====================
@@ -391,12 +362,6 @@ public class RelationshipService {
return matchMapper.findActiveMatch(userId, targetUserId).isPresent();
}
private void updateUserRelationshipStatus(String userId, String status) {
userMapper.update(null, new LambdaUpdateWrapper<com.danyang.match.platform.user.entity.User>()
.eq(com.danyang.match.platform.user.entity.User::getId, userId)
.set(com.danyang.match.platform.user.entity.User::getRelationshipStatus, status));
}
private MatchDTO toMatchDTO(String userId, Match match) {
MatchDTO dto = new MatchDTO();
dto.setMatchId(match.getId());
@@ -29,6 +29,7 @@ public final class Constants {
// 关系状态(对前端的统一表述,对齐小程序 RELATION_STATUS)
public static final String RELATION_STATUS_NEUTRAL = "neutral";
public static final String RELATION_STATUS_LIKED = "liked";
public static final String RELATION_STATUS_MUTUAL_MATCH = "mutual_match";
public static final String RELATION_STATUS_UNMATCHED = "unmatched";
@@ -0,0 +1,33 @@
package com.danyang.match.platform.shared.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/**
* 游标分页响应(全平台列表接口统一信封)
*
* 对齐小程序期望的 { items, nextCursor } 结构:
* - items:当前页数据
* - nextCursor:下一页游标;null 表示已到末页(前端据此置 hasMore=false)
*
* 注意:游标是不透明字符串,各端点的编码方式由该端点自行决定
* (消息用 messageId、会话用 lastMessageAt、黑名单用 offset),客户端只做透传。
*/
@Data
@AllArgsConstructor
public class CursorPage<T> {
private List<T> items;
private String nextCursor;
public static <T> CursorPage<T> of(List<T> items, String nextCursor) {
return new CursorPage<>(items, nextCursor);
}
/** 一次性返回全部结果(无更多分页) */
public static <T> CursorPage<T> all(List<T> items) {
return new CursorPage<>(items, null);
}
}
@@ -0,0 +1,115 @@
package com.danyang.match.platform.contract;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 路由契约测试:锁定「互动/匹配/成功牵手」域的 canonical 路径。
*
* 背景(2026-09-15):
* 同一能力曾同时存在两套路径 —— 2026-09-10 的 relationship 模块
* (/relations/{id}/like|pass|block、/relations/mutual|liked|likers、/relations/success/*)
* 与 2026-09-14 的 match 模块(/likes、/blocks、/matches)。
* 前端 matchService(08-29)走的是前者,且 /me/blacklist、/matches/{id}/success/* 后端完全无实现,
* 导致缘分三榜、黑名单页、成功牵手流程全部 404。
*
* 裁决:采用 2026-09-14 的路径(最新),前端向其对齐,被取代的旧路径移除。
* 本测试把这套裁决固化为断言,避免后续再次出现"两套路径并存/前端指向已删路径"。
*/
@SpringBootTest
@ActiveProfiles("test")
class CanonicalRouteContractTest {
/** 按名字取 MVC 的 HandlerMapping —— Actuator 也注册了一个同类型 bean,直接按类型注入会歧义 */
@Autowired
@Qualifier("requestMappingHandlerMapping")
private RequestMappingHandlerMapping handlerMapping;
/** 当前上下文已注册的全部 URL 模式 */
private Set<String> patterns() {
return handlerMapping.getHandlerMethods().keySet().stream()
.flatMap(info -> info.getPatternValues().stream())
.collect(Collectors.toSet());
}
@Test
@DisplayName("互动与匹配的 canonical 路径必须已注册")
void canonicalRoutesRegistered() {
Set<String> p = patterns();
// Like / Unlike
assertTrue(p.contains("/likes"), "缺少 POST /likes");
assertTrue(p.contains("/likes/{targetUserId}"), "缺少 DELETE /likes/{targetUserId}");
// Pass
assertTrue(p.contains("/passes"), "缺少 POST /passes");
// Block / Unblock / 黑名单列表
assertTrue(p.contains("/blocks"), "缺少 POST /blocks 与 GET /blocks");
assertTrue(p.contains("/blocks/{targetUserId}"), "缺少 DELETE /blocks/{targetUserId}");
// Match
assertTrue(p.contains("/matches"), "缺少 GET /matches");
assertTrue(p.contains("/matches/{id}"), "缺少 GET /matches/{id}");
assertTrue(p.contains("/matches/{id}/unmatch"), "缺少 POST /matches/{id}/unmatch");
// 成功牵手双确认(AGENTS 硬规则 6)
assertTrue(p.contains("/matches/{id}/success/request"), "缺少 POST /matches/{id}/success/request");
assertTrue(p.contains("/matches/{id}/success/confirm"), "缺少 POST /matches/{id}/success/confirm");
assertTrue(p.contains("/matches/{id}/success/decline"), "缺少 POST /matches/{id}/success/decline");
// 缘分三榜(无更新归属,保留在此前缀下)
assertTrue(p.contains("/relations/fate/mutual"), "缺少 GET /relations/fate/mutual");
assertTrue(p.contains("/relations/fate/liked"), "缺少 GET /relations/fate/liked");
assertTrue(p.contains("/relations/fate/likers"), "缺少 GET /relations/fate/likers");
}
@Test
@DisplayName("被 2026-09-14 路径取代的旧路由必须不再注册")
void supersededRoutesRemoved() {
Set<String> p = patterns();
assertFalse(p.contains("/relations/{targetUserId}/like"),
"旧路径 POST/DELETE /relations/{targetUserId}/like 应已由 /likes 取代");
assertFalse(p.contains("/relations/{targetUserId}/pass"),
"旧路径 POST /relations/{targetUserId}/pass 应已由 /passes 取代");
assertFalse(p.contains("/relations/{targetUserId}/block"),
"旧路径 POST/DELETE /relations/{targetUserId}/block 应已由 /blocks 取代");
assertFalse(p.contains("/relations/matches"),
"旧路径 GET /relations/matches 应已由 /matches 取代");
assertFalse(p.contains("/relations/matches/{targetUserId}/unmatch"),
"旧路径应已由 /matches/{id}/unmatch 取代");
assertFalse(p.contains("/relations/success/request"),
"桩实现 /relations/success/request 应已由 /matches/{id}/success/request 取代");
assertFalse(p.contains("/relations/success/confirm"),
"桩实现 /relations/success/confirm 应已由 /matches/{id}/success/confirm 取代");
assertFalse(p.contains("/relationship/success/request"),
"重复路由 /relationship/success/request 应已由 /matches/{id}/success/request 取代");
assertFalse(p.contains("/relationship/success/confirm"),
"重复路由 /relationship/success/confirm 应已由 /matches/{id}/success/confirm 取代");
assertFalse(p.contains("/relationship/success/decline"),
"重复路由 /relationship/success/decline 应已由 /matches/{id}/success/decline 取代");
}
@Test
@DisplayName("关系状态生命周期端点保留在 /relationship 下")
void relationshipStatusLifecycleKept() {
Set<String> p = patterns();
assertTrue(p.contains("/relationship/status"), "缺少 GET /relationship/status");
assertTrue(p.contains("/relationship/pause"), "缺少 PUT /relationship/pause");
assertTrue(p.contains("/relationship/resume"), "缺少 PUT /relationship/resume");
assertTrue(p.contains("/relationship/getting-to-know"), "缺少 PUT /relationship/getting-to-know");
assertTrue(p.contains("/relationship/exit"), "缺少 PUT /relationship/exit");
}
}