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:
+1
-1
@@ -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
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-9
@@ -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();
|
||||
LikeResult result = relationService.like(principal.id(), req.targetUserId);
|
||||
return ApiResponse.ok(LikeResponse.from(result));
|
||||
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, 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;
|
||||
}
|
||||
|
||||
+77
-4
@@ -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 端点
|
||||
* 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 — 发起/确认成功牵手(Phase 12 预留)
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
+28
-141
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+10
-47
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出服务
|
||||
|
||||
+10
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
+115
@@ -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");
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -18,7 +18,9 @@
|
||||
| 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 |
|
||||
| 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md |
|
||||
| Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS |
|
||||
| Backend 全量回归 | **债务待清** | 277 tests:4 failures、8 errors、3 skipped;本地无 JDK 无法复验 |
|
||||
| Backend 全量回归 | **已清偿**(2026-09-15) | 离线容器(`danyang-match-backend-test`)运行:**297 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含新增 `CanonicalRouteContractTest` 与 `ChatContractJsonTest` |
|
||||
| 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 |
|
||||
| `/me/*` 路径族 | **待裁决** | 小程序按 `/me/*` 编写,后端实为 `/profile/*`、`/photos`、`/relationship/*`;影响资料/照片/择偶/隐私/关系状态/申诉等约 15 个端点,收敛方向未定,见 `docs/api/API_INVENTORY.md` 顶部警告 |
|
||||
| Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA |
|
||||
| Miniapp | **验证通过** | 2026-09-14 阶段 4:tsc ✅;11 核心页中 9 TS-only(index/discover/chat/local-eligibility/counseling-intro/premarital-intro/content/anti-fraud/service-station)、3 JS-only 待迁(matchmaker/messages/mine);discover 筛选重设计已通过 DevTools 验证 |
|
||||
| UI 契约测试 | **13/13 通过** | 2026-09-14 阶段 4 运行:全部退出码 0;含 capability 独立化适配 |
|
||||
|
||||
+38
-10
@@ -2,6 +2,18 @@
|
||||
|
||||
## 用户端 /api/v1/
|
||||
|
||||
> ⚠️ **待收敛:`/me/*` 系列**
|
||||
>
|
||||
> 本清单的 `/me/*` 个人资源路径(用户资料、照片、择偶条件、隐私设置、关系状态、我的活动/举报/申诉等)
|
||||
> 与后端**已实现路径不一致**。后端实际前缀为:
|
||||
> `/profile/me`、`/profile/preferences`、`/profile/privacy`、`/profile/interests`、
|
||||
> `/photos`、`/relationship/status|pause|resume|getting-to-know|exit`、`/appeals`、`/reports`。
|
||||
> 小程序 `services/*.js` 当前按本清单的 `/me/*` 编写,调用会 404。
|
||||
>
|
||||
> 关系 / 匹配 / 缘分 三个域已于 2026-09-15 按「采用 2026-09-14 最新路径」的裁决收敛完成(见下方「关系」「我的缘分」「匹配」)。
|
||||
> `/me/*` 系列的收敛方向尚未裁决,**改动前请勿直接照抄本清单中的 `/me/*` 路径**。
|
||||
> 路由裁决已被 `backend/.../contract/CanonicalRouteContractTest.java` 锁定。
|
||||
|
||||
### 认证
|
||||
| Method | Path | 描述 |
|
||||
|---|---|---|
|
||||
@@ -42,25 +54,41 @@
|
||||
| GET | /discover | 搜索用户 |
|
||||
| GET | /users/{id} | 查看用户详情 |
|
||||
|
||||
### 关系
|
||||
### 关系(Like / Pass / Block)
|
||||
> 基线:2026-09-14 canonical 路径。旧的 `/relations/{userId}/like|pass|block` 已废止。
|
||||
> 列表接口统一返回 `{ items, nextCursor }`。
|
||||
|
||||
| Method | Path | 描述 | 请求体 |
|
||||
|---|---|---|---|
|
||||
| POST | /likes | 表达喜欢 | `{ targetUserId }` |
|
||||
| DELETE | /likes/{targetUserId} | 取消喜欢(已 Mutual 需先 Unmatch) | — |
|
||||
| POST | /passes | 表示不合适(单向保密,不通知对方) | `{ targetUserId }` |
|
||||
| POST | /blocks | 拉黑用户(Block 优先级最高) | `{ targetUserId }` |
|
||||
| DELETE | /blocks/{targetUserId} | 解除拉黑 | — |
|
||||
| GET | /blocks | 我的黑名单列表(黑名单页) | — |
|
||||
|
||||
`POST /likes` 响应:`{ relationStatus, mutualMatch, matchedConversationId, matchId }`
|
||||
(对齐 `miniapp/types/index.d.ts#LikeResult`;Mutual 时 `matchedConversationId` 可直接用于跳转聊天)
|
||||
|
||||
### 我的缘分(只读)
|
||||
> `FateItemDTO` 字段:userId / displayName / age / heightCm / regionName / educationCode /
|
||||
> occupationCode / maritalStatus / photos / verified / reviewed / relationType / relationTime
|
||||
|
||||
| Method | Path | 描述 |
|
||||
|---|---|---|
|
||||
| POST | /relations/{userId}/like | 表达喜欢 |
|
||||
| DELETE | /relations/{userId}/like | 取消喜欢 |
|
||||
| POST | /relations/{userId}/pass | 表示不合适 |
|
||||
| POST | /relations/{userId}/block | 拉黑用户 |
|
||||
| DELETE | /relations/{userId}/block | 解除拉黑 |
|
||||
| GET | /relations/mutual | 获取互相喜欢列表 |
|
||||
| GET | /relations/liked | 获取喜欢的列表 |
|
||||
| GET | /relations/likers | 获取喜欢我的人 |
|
||||
| GET | /relations/fate/mutual | 互相喜欢 |
|
||||
| GET | /relations/fate/liked | 我喜欢的 |
|
||||
| GET | /relations/fate/likers | 喜欢我的(仅脱敏字段,不泄露明示身份) |
|
||||
|
||||
### 匹配
|
||||
| Method | Path | 描述 |
|
||||
|---|---|---|
|
||||
| GET | /matches | 获取我的匹配列表 |
|
||||
| GET | /matches/{id} | 匹配详情 |
|
||||
| POST | /matches/{id}/unmatch | 解除匹配 |
|
||||
| POST | /matches/{id}/success/request | 发起成功牵手请求 |
|
||||
| POST | /matches/{id}/success/confirm | 确认成功牵手 |
|
||||
| POST | /matches/{id}/success/confirm | 确认成功牵手(双确认第二步) |
|
||||
| POST | /matches/{id}/success/decline | 拒绝成功牵手 |
|
||||
|
||||
### 关系状态
|
||||
| Method | Path | 描述 |
|
||||
|
||||
@@ -1,122 +1,148 @@
|
||||
/**
|
||||
* 关系与匹配服务(API_INVENTORY 行 48-55, 60-63)
|
||||
* 关系与匹配服务
|
||||
*
|
||||
* 封装:Like/Pass/Block/取消/解除 + 缘分三榜 + 黑名单 + Unmatch + 成功牵手双确认。
|
||||
*
|
||||
* 路径基线:2026-09-14 的 match 模块(LikeController/PassController/BlockController/MatchController)
|
||||
* 为 canonical 路径 —— /likes、/passes、/blocks、/matches;缘分三榜保留 /relations/fate/*。
|
||||
* 旧的 /relations/{id}/like|pass|block、/relations/mutual|liked|likers、/me/blacklist 已废止。
|
||||
*
|
||||
* 封装:Like/Pass/Block/取消/解除 + Mutual 列表 + Unmatch + 成功牵手双确认。
|
||||
* 关键不变量(AGENTS.md):
|
||||
* - 第 4 条:Block 即时禁止互动——前端调用 block 后必须立即更新本地会话与列表。
|
||||
* - 第 5 条:红娘不可替用户 Like——本层仅供当前用户调用,不接受 matchmakerId 参数。
|
||||
* - 第 6 条:成功牵手必须双确认——success/request 与 success/confirm 分两次调用。
|
||||
* 单向意向不泄露:A Like B 但未 Mutual,本层不提供"谁喜欢我"的可识别列表,
|
||||
* 仅返回计数 / 是否被 liked 的布尔,详情走 /relations/likers(仅返回匿名化字段)。
|
||||
* 仅返回计数 / 是否被 liked 的布尔,详情走 /relations/fate/likers(仅返回脱敏字段)。
|
||||
*/
|
||||
const api = require('../utils/api');
|
||||
|
||||
/**
|
||||
* 表达喜欢(API_INVENTORY 行 48)
|
||||
* 表达喜欢
|
||||
* POST /likes { targetUserId }
|
||||
*
|
||||
* 响应对齐 types.LikeResult:{ relationStatus, mutualMatch, matchedConversationId }。
|
||||
* mutualMatch=true 且 matchedConversationId 非空时,调用方可直接跳转聊天页。
|
||||
* @param {string} userId
|
||||
* @returns {Promise<import('../types').LikeResult>}
|
||||
*/
|
||||
function like(userId) {
|
||||
const idempotencyKey = `like:${userId}:${Date.now()}`;
|
||||
return api.post(`/relations/${encodeURIComponent(userId)}/like`, {}, undefined, idempotencyKey);
|
||||
return api.post('/likes', { targetUserId: userId }, undefined, idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消喜欢(API_INVENTORY 行 49)
|
||||
* 注意:Mutual 后不可直接取消(后端会返回 MATCH_REQUIRED),前端不应阻断调用。
|
||||
* 取消喜欢
|
||||
* DELETE /likes/{userId}
|
||||
*
|
||||
* 注意:Mutual 后不可直接取消(后端返回状态冲突,提示先解除匹配),前端不应阻断调用。
|
||||
* @param {string} userId
|
||||
* @returns {Promise<{ relationStatus: import('../types').RelationStatus }>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function unlike(userId) {
|
||||
return api.delete(`/relations/${encodeURIComponent(userId)}/like`, {}, undefined, `unlike:${userId}:${Date.now()}`);
|
||||
return api.delete(`/likes/${encodeURIComponent(userId)}`, {}, undefined, `unlike:${userId}:${Date.now()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表示不合适(API_INVENTORY 行 50)
|
||||
* 表示不合适
|
||||
* POST /passes { targetUserId }
|
||||
*
|
||||
* Pass 是单向意向,不会通知对方(AGENTS 第 6 条)。
|
||||
* @param {string} userId
|
||||
* @returns {Promise<{ relationStatus: 'passed' }>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function pass(userId) {
|
||||
const idempotencyKey = `pass:${userId}:${Date.now()}`;
|
||||
return api.post(`/relations/${encodeURIComponent(userId)}/pass`, {}, undefined, idempotencyKey);
|
||||
return api.post('/passes', { targetUserId: userId }, undefined, idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉黑用户(API_INVENTORY 行 51,AGENTS 第 4 条:Block 优先级最高)
|
||||
* 拉黑用户(AGENTS 第 4 条:Block 优先级最高)
|
||||
* POST /blocks { targetUserId, reason? }
|
||||
*
|
||||
* 前端立即停止与该用户的所有互动,从推荐/搜索/会话列表移除。
|
||||
* @param {string} userId
|
||||
* @param {{ reason?: string }} [payload]
|
||||
* @returns {Promise<{ relationStatus: 'blocked' }>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function block(userId, payload = {}) {
|
||||
const idempotencyKey = `block:${userId}:${Date.now()}`;
|
||||
return api.post(`/relations/${encodeURIComponent(userId)}/block`, payload, undefined, idempotencyKey);
|
||||
return api.post('/blocks', { targetUserId: userId, reason: payload.reason }, undefined, idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除拉黑(API_INVENTORY 行 52)
|
||||
* 解除拉黑
|
||||
* DELETE /blocks/{userId}
|
||||
* @param {string} userId
|
||||
* @returns {Promise<{ relationStatus: 'neutral' }>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function unblock(userId) {
|
||||
return api.delete(`/relations/${encodeURIComponent(userId)}/block`, {}, undefined, `unblock:${userId}:${Date.now()}`);
|
||||
return api.delete(`/blocks/${encodeURIComponent(userId)}`, {}, undefined, `unblock:${userId}:${Date.now()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取互相喜欢列表(API_INVENTORY 行 53,即 Mutual Matches)
|
||||
* 获取互相喜欢列表(Mutual Matches)
|
||||
* GET /relations/fate/mutual
|
||||
* @param {{ cursor?: string, pageSize?: number }} params
|
||||
* @returns {Promise<{ items: Array<import('../types').Conversation>, nextCursor: string|null }>}
|
||||
* @returns {Promise<{ items: Array<import('../types').User>, nextCursor: string|null }>}
|
||||
*/
|
||||
function getMutualList(params = {}) {
|
||||
return api.get('/relations/mutual', params);
|
||||
return api.get('/relations/fate/mutual', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我喜欢的人列表(API_INVENTORY 行 54)
|
||||
* 获取我喜欢的人列表
|
||||
* GET /relations/fate/liked
|
||||
* @param {{ cursor?: string, pageSize?: number }} params
|
||||
* @returns {Promise<{ items: Array<import('../types').User & { likedAt: number }>, nextCursor: string|null }>}
|
||||
* @returns {Promise<{ items: Array<import('../types').User & { relationTime?: number }>, nextCursor: string|null }>}
|
||||
*/
|
||||
function getLikedByMe(params = {}) {
|
||||
return api.get('/relations/liked', params);
|
||||
return api.get('/relations/fate/liked', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取喜欢我的人列表(API_INVENTORY 行 55)
|
||||
* 获取喜欢我的人列表
|
||||
* GET /relations/fate/likers
|
||||
*
|
||||
* 仅返回脱敏字段(昵称/年龄/地区/是否已 Mutual),不泄露对方明示身份。
|
||||
* @param {{ cursor?: string, pageSize?: number }} params
|
||||
* @returns {Promise<{ items: Array<import('../types').User>, nextCursor: string|null, totalCount: number }>}
|
||||
* @returns {Promise<{ items: Array<import('../types').FateItem>, nextCursor: string|null }>}
|
||||
*/
|
||||
function getLikers(params = {}) {
|
||||
return api.get('/relations/likers', params);
|
||||
return api.get('/relations/fate/likers', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取黑名单列表(REST §86 章 GET /me/blacklist)
|
||||
* 拉黑/解除拉黑统一走 Relationship 接口(block/unblock),本接口仅查询。
|
||||
* 获取黑名单列表(黑名单管理页)
|
||||
* GET /blocks
|
||||
*
|
||||
* 拉黑/解除拉黑走 blocks 的 POST/DELETE,本接口仅查询。
|
||||
* 返回字段已脱敏(不返回对方手机号/身份证等敏感信息)。
|
||||
* @param {{ cursor?: string, pageSize?: number }} params
|
||||
* @returns {Promise<{ items: Array<{ userId: string, userName: string, avatarUrl?: string, blockedAt: number, reason?: string }>, nextCursor: string|null }>}
|
||||
* @returns {Promise<{ items: Array<{ userId: string, userName: string, avatarUrl: string|null, blockedAt: number|null, reason: string|null }>, nextCursor: string|null }>}
|
||||
*/
|
||||
function getBlockedList(params = {}) {
|
||||
return api.get('/me/blacklist', params);
|
||||
return api.get('/blocks', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的匹配列表(API_INVENTORY 行 60)
|
||||
* 与 /relations/mutual 区别:matches 包含牵手状态、服务跟进字段,会话列表专用。
|
||||
* 获取我的匹配列表
|
||||
* GET /matches
|
||||
*
|
||||
* 与缘分 mutual 区别:matches 含 Match 状态、episode、服务跟进字段。
|
||||
* @param {{ cursor?: string, pageSize?: number }} params
|
||||
* @returns {Promise<{ items: Array<import('../types').Conversation>, nextCursor: string|null }>}
|
||||
* @returns {Promise<{ items: Array<{ id: string, source: string, sourceId: string|null, status: string, peerUserId: string, matchedAt: number|null, lastActivityAt: number|null, episode: number }>, nextCursor: string|null }>}
|
||||
*/
|
||||
function getMatches(params = {}) {
|
||||
return api.get('/matches', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除匹配(API_INVENTORY 行 61)
|
||||
* 解除后双方会话立即停用,AGENTS 第 4 条影响范围仅次于 Block。
|
||||
* 解除匹配
|
||||
* POST /matches/{matchId}/unmatch
|
||||
*
|
||||
* 解除后双方会话立即停用,影响范围仅次于 Block。
|
||||
* @param {string} matchId
|
||||
* @returns {Promise<{ relationStatus: 'unmatched' }>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function unmatch(matchId) {
|
||||
const idempotencyKey = `unmatch:${matchId}:${Date.now()}`;
|
||||
@@ -124,10 +150,12 @@ function unmatch(matchId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起成功牵手请求(API_INVENTORY 行 62,AGENTS 第 6 条:成功牵手必须双确认)
|
||||
* 发起成功牵手请求(AGENTS 第 6 条:成功牵手必须双确认)
|
||||
* POST /matches/{matchId}/success/request
|
||||
*
|
||||
* 仅发起方调用一次,需对方在有效期内 confirm。
|
||||
* @param {string} matchId
|
||||
* @returns {Promise<{ successRequestId: string, expiresAt: number, status: 'pending_confirm' }>}
|
||||
* @returns {Promise<{ requestId: string, initiatedBy: string, status: 'pending' }>}
|
||||
*/
|
||||
function requestSuccess(matchId) {
|
||||
const idempotencyKey = `success-req:${matchId}:${Date.now()}`;
|
||||
@@ -135,16 +163,29 @@ function requestSuccess(matchId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认成功牵手(API_INVENTORY 行 63)
|
||||
* 仅被请求方调用,确认后双方关系置为 matched_successfully。
|
||||
* 确认成功牵手
|
||||
* POST /matches/{matchId}/success/confirm
|
||||
*
|
||||
* 仅被请求方调用,双方都确认后关系置为 matched_successfully。
|
||||
* @param {string} matchId
|
||||
* @returns {Promise<{ relationshipStatus: 'matched_successfully', confirmedAt: number }>}
|
||||
* @returns {Promise<{ relationshipStatus: string }>}
|
||||
*/
|
||||
function confirmSuccess(matchId) {
|
||||
const idempotencyKey = `success-confirm:${matchId}:${Date.now()}`;
|
||||
return api.post(`/matches/${encodeURIComponent(matchId)}/success/confirm`, {}, undefined, idempotencyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝成功牵手
|
||||
* POST /matches/{matchId}/success/decline
|
||||
* @param {string} matchId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function declineSuccess(matchId) {
|
||||
const idempotencyKey = `success-decline:${matchId}:${Date.now()}`;
|
||||
return api.post(`/matches/${encodeURIComponent(matchId)}/success/decline`, {}, undefined, idempotencyKey);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
like,
|
||||
unlike,
|
||||
@@ -159,4 +200,5 @@ module.exports = {
|
||||
unmatch,
|
||||
requestSuccess,
|
||||
confirmSuccess,
|
||||
declineSuccess,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
const BaseStore = require('./baseStore');
|
||||
const { matchService, recommendationService } = require('../services');
|
||||
const { RECOMMENDATION_STATE, RELATION_STATUS } = require('../constants');
|
||||
const { formatConversationTime } = require('../utils/datetime');
|
||||
|
||||
class MatchStore extends BaseStore {
|
||||
constructor() {
|
||||
@@ -276,7 +277,7 @@ class MatchStore extends BaseStore {
|
||||
async fetchBlockedList(params = {}) {
|
||||
const result = await matchService.getBlockedList(params);
|
||||
this.setState({
|
||||
blockedList: result.items || [],
|
||||
blockedList: this._decorateBlockedList(result.items),
|
||||
blockedCursor: result.nextCursor || null,
|
||||
blockedHasMore: !!result.nextCursor,
|
||||
});
|
||||
@@ -288,13 +289,27 @@ class MatchStore extends BaseStore {
|
||||
if (!this.state.blockedCursor || !this.state.blockedHasMore) return;
|
||||
const result = await matchService.getBlockedList({ cursor: this.state.blockedCursor });
|
||||
this.setState({
|
||||
blockedList: [...this.state.blockedList, ...(result.items || [])],
|
||||
blockedList: [...this.state.blockedList, ...this._decorateBlockedList(result.items)],
|
||||
blockedCursor: result.nextCursor || null,
|
||||
blockedHasMore: !!result.nextCursor,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑名单列表装饰
|
||||
*
|
||||
* pages/blacklist 模板读取 `item.blockedAtText`,但该字段服务端不产出
|
||||
* (后端只给 epoch 毫秒的 blockedAt),故进入 store 时统一格式化一次,
|
||||
* 与 messageStore._decorateConversations 同构,避免每个页面各自实现。
|
||||
*/
|
||||
_decorateBlockedList(items) {
|
||||
return (items || []).map((u) => ({
|
||||
...u,
|
||||
blockedAtText: formatConversationTime(u.blockedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 拉取喜欢我的人计数(不暴露具体身份) */
|
||||
async fetchLikersCount() {
|
||||
const result = await matchService.getLikers({ pageSize: 1 });
|
||||
|
||||
Reference in New Issue
Block a user