diff --git a/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java b/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java index 0acae3c..d067470 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/controller/ConversationController.java @@ -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; diff --git a/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java b/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java index 0d9f3bb..ed69459 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java @@ -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; diff --git a/backend/src/main/java/com/danyang/match/platform/chat/dto/CursorPage.java b/backend/src/main/java/com/danyang/match/platform/chat/dto/CursorPage.java deleted file mode 100644 index 450e2cc..0000000 --- a/backend/src/main/java/com/danyang/match/platform/chat/dto/CursorPage.java +++ /dev/null @@ -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 { - - private List items; - private String nextCursor; - - public static CursorPage of(List items, String nextCursor) { - return new CursorPage<>(items, nextCursor); - } -} diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java index 18b42da..21359bf 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/BlockController.java @@ -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 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> list( + @RequestParam(required = false) String cursor, + @RequestParam(required = false, defaultValue = "20") int pageSize, + @AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + + List all = relationMapper.findBlockedByUser(principal.id()); + int offset = parseOffset(cursor); + int size = Math.max(1, Math.min(pageSize, MAX_PAGE_SIZE)); + + List 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; + } + } } diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java index baa490e..eb9af77 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java @@ -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 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 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; } diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java index 263637e..347abf8 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/MatchController.java @@ -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(@AuthenticationPrincipal UserPrincipal principal) { + public ApiResponse> list(@AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); List matches = matchMapper.findActiveByUser(principal.id()); - return ApiResponse.ok(matches.stream().map(MatchResponse::from).collect(Collectors.toList())); + List 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> 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> 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 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 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 diff --git a/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java b/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java new file mode 100644 index 0000000..ad87fef --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/match/controller/PassController.java @@ -0,0 +1,46 @@ +package com.danyang.match.platform.match.controller; + +import com.danyang.match.platform.match.service.RelationService; +import com.danyang.match.platform.shared.dto.ApiResponse; +import com.danyang.match.platform.shared.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 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; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java b/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java index 5db1464..5993bcc 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/UserRelationMapper.java @@ -34,8 +34,8 @@ public interface UserRelationMapper extends BaseMapper { @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 findBlockedByUser(String userId); /** 喜欢我的人 */ diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java index 0fb7030..cd31776 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RelationService.java @@ -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 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 ==================== /** diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipController.java b/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipController.java index 2dffbc0..f7c0dde 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipController.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipController.java @@ -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> 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 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 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 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 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> 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 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> getMutual(@AuthenticationPrincipal UserPrincipal principal) { - String userId = principal.id(); - return ApiResponse.ok(relationshipService.getMutualList(userId)); + public ApiResponse> 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> getLiked(@AuthenticationPrincipal UserPrincipal principal) { - String userId = principal.id(); - return ApiResponse.ok(relationshipService.getLikedList(userId)); + public ApiResponse> 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> getLikers(@AuthenticationPrincipal UserPrincipal principal) { - String userId = principal.id(); - return ApiResponse.ok(relationshipService.getLikersList(userId)); + public ApiResponse> 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> requestSuccess( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map 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 confirmSuccess( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map body) { - String userId = principal.id(); - String targetUserId = body.get("targetUserId"); - relationshipService.confirmSuccess(userId, targetUserId); - return ApiResponse.ok(); - } - - } diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipStatusController.java b/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipStatusController.java index 7a03c22..d92e087 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipStatusController.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/controller/RelationshipStatusController.java @@ -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> requestSuccess( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map 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> confirmSuccess( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map 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 declineSuccess( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map body) { - String userId = principal.id(); - String targetUserId = body.get("targetUserId"); - relationshipStatusService.declineSuccess(userId, targetUserId); - return ApiResponse.ok(); - } /** * 退出服务 diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java index baa34dd..cef45f9 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java @@ -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 requestSuccess(String userId, String targetUserId) { - Match match = matchMapper.findActiveMatch(userId, targetUserId) - .orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系")); - - // 检查是否已有pending的请求 - // TODO: 查询 relationship_success_requests 表 - - Map 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() - .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()); diff --git a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java index 704aadc..c430f2a 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java @@ -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"; diff --git a/backend/src/main/java/com/danyang/match/platform/shared/dto/CursorPage.java b/backend/src/main/java/com/danyang/match/platform/shared/dto/CursorPage.java new file mode 100644 index 0000000..f679429 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/dto/CursorPage.java @@ -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 { + + private List items; + private String nextCursor; + + public static CursorPage of(List items, String nextCursor) { + return new CursorPage<>(items, nextCursor); + } + + /** 一次性返回全部结果(无更多分页) */ + public static CursorPage all(List items) { + return new CursorPage<>(items, null); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/contract/CanonicalRouteContractTest.java b/backend/src/test/java/com/danyang/match/platform/contract/CanonicalRouteContractTest.java new file mode 100644 index 0000000..14f0a47 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/contract/CanonicalRouteContractTest.java @@ -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 patterns() { + return handlerMapping.getHandlerMethods().keySet().stream() + .flatMap(info -> info.getPatternValues().stream()) + .collect(Collectors.toSet()); + } + + @Test + @DisplayName("互动与匹配的 canonical 路径必须已注册") + void canonicalRoutesRegistered() { + Set 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 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 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"); + } +} diff --git a/docs/STATUS.md b/docs/STATUS.md index c91d9bb..4906e1d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 独立化适配 | diff --git a/docs/api/API_INVENTORY.md b/docs/api/API_INVENTORY.md index 75c42d0..69cae5a 100644 --- a/docs/api/API_INVENTORY.md +++ b/docs/api/API_INVENTORY.md @@ -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 | 描述 | diff --git a/miniapp/services/matchService.js b/miniapp/services/matchService.js index 395e3fb..380b075 100644 --- a/miniapp/services/matchService.js +++ b/miniapp/services/matchService.js @@ -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} */ 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} */ 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} */ 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} */ 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} */ 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, nextCursor: string|null }>} + * @returns {Promise<{ items: Array, 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, nextCursor: string|null }>} + * @returns {Promise<{ items: Array, 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, nextCursor: string|null, totalCount: number }>} + * @returns {Promise<{ items: Array, 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, 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} */ 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} + */ +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, }; diff --git a/miniapp/stores/matchStore.js b/miniapp/stores/matchStore.js index fcebe06..f250e20 100644 --- a/miniapp/stores/matchStore.js +++ b/miniapp/stores/matchStore.js @@ -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 });