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

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

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

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

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

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

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

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

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

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

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

## 验证
- 新增 CanonicalRouteContractTest(3 例):用 RequestMappingHandlerMapping 断言
  canonical 路径已注册、被取代的旧路径不再注册、关系状态生命周期保留 —— 路由再漂移即红
- 后端 297 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
- 小程序 tsc --noEmit 通过
This commit is contained in:
Codex
2026-09-15 23:16:27 +08:00
parent 687a156350
commit 3244e02e7e
19 changed files with 636 additions and 333 deletions
+84 -42
View File
@@ -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,
};
+17 -2
View File
@@ -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 });