Files
danyang-match/miniapp/services/matchService.js
T
Codex 3244e02e7e 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 通过
2026-09-15 23:16:27 +08:00

205 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 关系与匹配服务
*
* 封装: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 已废止。
*
* 关键不变量(AGENTS.md):
* - 第 4 条:Block 即时禁止互动——前端调用 block 后必须立即更新本地会话与列表。
* - 第 5 条:红娘不可替用户 Like——本层仅供当前用户调用,不接受 matchmakerId 参数。
* - 第 6 条:成功牵手必须双确认——success/request 与 success/confirm 分两次调用。
* 单向意向不泄露:A Like B 但未 Mutual,本层不提供"谁喜欢我"的可识别列表,
* 仅返回计数 / 是否被 liked 的布尔,详情走 /relations/fate/likers(仅返回脱敏字段)。
*/
const api = require('../utils/api');
/**
* 表达喜欢
* 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('/likes', { targetUserId: userId }, undefined, idempotencyKey);
}
/**
* 取消喜欢
* DELETE /likes/{userId}
*
* 注意:Mutual 后不可直接取消(后端返回状态冲突,提示先解除匹配),前端不应阻断调用。
* @param {string} userId
* @returns {Promise<void>}
*/
function unlike(userId) {
return api.delete(`/likes/${encodeURIComponent(userId)}`, {}, undefined, `unlike:${userId}:${Date.now()}`);
}
/**
* 表示不合适
* POST /passes { targetUserId }
*
* Pass 是单向意向,不会通知对方(AGENTS 第 6 条)。
* @param {string} userId
* @returns {Promise<void>}
*/
function pass(userId) {
const idempotencyKey = `pass:${userId}:${Date.now()}`;
return api.post('/passes', { targetUserId: userId }, undefined, idempotencyKey);
}
/**
* 拉黑用户(AGENTS 第 4 条:Block 优先级最高)
* POST /blocks { targetUserId, reason? }
*
* 前端立即停止与该用户的所有互动,从推荐/搜索/会话列表移除。
* @param {string} userId
* @param {{ reason?: string }} [payload]
* @returns {Promise<void>}
*/
function block(userId, payload = {}) {
const idempotencyKey = `block:${userId}:${Date.now()}`;
return api.post('/blocks', { targetUserId: userId, reason: payload.reason }, undefined, idempotencyKey);
}
/**
* 解除拉黑
* DELETE /blocks/{userId}
* @param {string} userId
* @returns {Promise<void>}
*/
function unblock(userId) {
return api.delete(`/blocks/${encodeURIComponent(userId)}`, {}, undefined, `unblock:${userId}:${Date.now()}`);
}
/**
* 获取互相喜欢列表(Mutual Matches)
* GET /relations/fate/mutual
* @param {{ cursor?: string, pageSize?: number }} params
* @returns {Promise<{ items: Array<import('../types').User>, nextCursor: string|null }>}
*/
function getMutualList(params = {}) {
return api.get('/relations/fate/mutual', params);
}
/**
* 获取我喜欢的人列表
* GET /relations/fate/liked
* @param {{ cursor?: string, pageSize?: number }} params
* @returns {Promise<{ items: Array<import('../types').User & { relationTime?: number }>, nextCursor: string|null }>}
*/
function getLikedByMe(params = {}) {
return api.get('/relations/fate/liked', params);
}
/**
* 获取喜欢我的人列表
* GET /relations/fate/likers
*
* 仅返回脱敏字段(昵称/年龄/地区/是否已 Mutual),不泄露对方明示身份。
* @param {{ cursor?: string, pageSize?: number }} params
* @returns {Promise<{ items: Array<import('../types').FateItem>, nextCursor: string|null }>}
*/
function getLikers(params = {}) {
return api.get('/relations/fate/likers', params);
}
/**
* 获取黑名单列表(黑名单管理页)
* GET /blocks
*
* 拉黑/解除拉黑走 blocks 的 POST/DELETE,本接口仅查询。
* 返回字段已脱敏(不返回对方手机号/身份证等敏感信息)。
* @param {{ cursor?: string, pageSize?: number }} params
* @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('/blocks', params);
}
/**
* 获取我的匹配列表
* GET /matches
*
* 与缘分 mutual 区别:matches 含 Match 状态、episode、服务跟进字段。
* @param {{ cursor?: string, pageSize?: number }} params
* @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);
}
/**
* 解除匹配
* POST /matches/{matchId}/unmatch
*
* 解除后双方会话立即停用,影响范围仅次于 Block。
* @param {string} matchId
* @returns {Promise<void>}
*/
function unmatch(matchId) {
const idempotencyKey = `unmatch:${matchId}:${Date.now()}`;
return api.post(`/matches/${encodeURIComponent(matchId)}/unmatch`, {}, undefined, idempotencyKey);
}
/**
* 发起成功牵手请求(AGENTS 第 6 条:成功牵手必须双确认)
* POST /matches/{matchId}/success/request
*
* 仅发起方调用一次,需对方在有效期内 confirm。
* @param {string} matchId
* @returns {Promise<{ requestId: string, initiatedBy: string, status: 'pending' }>}
*/
function requestSuccess(matchId) {
const idempotencyKey = `success-req:${matchId}:${Date.now()}`;
return api.post(`/matches/${encodeURIComponent(matchId)}/success/request`, {}, undefined, idempotencyKey);
}
/**
* 确认成功牵手
* POST /matches/{matchId}/success/confirm
*
* 仅被请求方调用,双方都确认后关系置为 matched_successfully。
* @param {string} matchId
* @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,
pass,
block,
unblock,
getMutualList,
getLikedByMe,
getLikers,
getBlockedList,
getMatches,
unmatch,
requestSuccess,
confirmSuccess,
declineSuccess,
};