## 问题:同一能力存在三代路径,前端指向的是已废止的一代
按 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 通过
385 lines
13 KiB
JavaScript
385 lines
13 KiB
JavaScript
/**
|
||
* MatchStore(规范 §131)
|
||
*
|
||
* 职责:
|
||
* - 维护今日推荐列表(游标分页 + 加载状态)
|
||
* - 维护 Mutual Matches(互相喜欢)列表
|
||
* - 维护我喜欢 / 喜欢我 计数(计数仅作徽标提示,不暴露具体身份)
|
||
* - 处理 Like/Pass/Block/Unmatch/成功牵手双确认 等动作
|
||
*
|
||
* 不变量(AGENTS.md):
|
||
* - 第 2 条:Required 不可自动放宽——推荐列表完全由后端按 PartnerPreference 过滤,
|
||
* 前端不做任何"放宽条件再查"的逻辑。
|
||
* - 第 4 条:Block 即时禁止互动——block 成功后,本地立即从推荐/Mutual 列表移除该用户。
|
||
* - 第 5 条:红娘不可替用户 Like——本 Store 只暴露当前用户操作 Like/Pass 的接口。
|
||
* - 第 6 条:成功牵手必须双确认——requestSuccess 与 confirmSuccess 分两次调用,
|
||
* Store 内部维护 successPendingMatchIds 表示谁正在等待对方确认。
|
||
* - 第 6 条(隐式):单向意向不泄露——A Like B 但 B 未 Mutual 时,
|
||
* getLikers 不在 Store 中暴露 B 的具体身份(仅后端返回的脱敏字段)。
|
||
*/
|
||
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() {
|
||
super({
|
||
/** @type {import('../types').Recommendation[]} */
|
||
recommendations: [],
|
||
/** @type {RECOMMENDATION_STATE[keyof RECOMMENDATION_STATE]} */
|
||
recState: RECOMMENDATION_STATE.IDLE,
|
||
recCursor: null,
|
||
dailyRemaining: 0,
|
||
|
||
/** @type {import('../types').Conversation[]} */
|
||
mutualList: [],
|
||
mutualCursor: null,
|
||
mutualHasMore: true,
|
||
|
||
/** 我喜欢的人列表(缘分中心 Tab) */
|
||
likedList: [],
|
||
likedCursor: null,
|
||
likedHasMore: true,
|
||
|
||
/** 喜欢我的人列表(缘分中心 Tab,仅脱敏字段) */
|
||
likersList: [],
|
||
likersCursor: null,
|
||
likersHasMore: true,
|
||
|
||
/** 黑名单列表(脱敏) */
|
||
blockedList: [],
|
||
blockedCursor: null,
|
||
blockedHasMore: true,
|
||
|
||
likersCount: 0,
|
||
likedByMeCount: 0,
|
||
|
||
/** 正在等待对方确认成功牵手的 matchId 集合 */
|
||
successPendingMatchIds: [],
|
||
|
||
lastError: null,
|
||
});
|
||
/** 进行中的 action 防重锁,key=userId/matchId */
|
||
this._actionLocks = new Set();
|
||
}
|
||
|
||
/** 获取今日推荐(首次) */
|
||
async fetchRecommendations() {
|
||
if (this.state.recState === RECOMMENDATION_STATE.LOADING) return;
|
||
this.setState({ recState: RECOMMENDATION_STATE.LOADING, lastError: null });
|
||
try {
|
||
const result = await recommendationService.getRecommendations({ pageSize: 10 });
|
||
const items = result.items || [];
|
||
this.setState({
|
||
recommendations: items,
|
||
recCursor: result.nextCursor || null,
|
||
dailyRemaining: result.dailyRemaining ?? 0,
|
||
recState: items.length === 0 ? RECOMMENDATION_STATE.EMPTY : RECOMMENDATION_STATE.LOADED,
|
||
});
|
||
return result;
|
||
} catch (err) {
|
||
this.setState({ recState: RECOMMENDATION_STATE.ERROR, lastError: err });
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** 加载更多推荐 */
|
||
async loadMoreRecommendations() {
|
||
if (this.state.recState === RECOMMENDATION_STATE.LOADING) return;
|
||
if (!this.state.recCursor) {
|
||
this.setState({ recState: RECOMMENDATION_STATE.FINISHED });
|
||
return;
|
||
}
|
||
this.setState({ recState: RECOMMENDATION_STATE.LOADING });
|
||
try {
|
||
const result = await recommendationService.getRecommendations({
|
||
cursor: this.state.recCursor,
|
||
pageSize: 10,
|
||
});
|
||
const items = result.items || [];
|
||
this.setState({
|
||
recommendations: [...this.state.recommendations, ...items],
|
||
recCursor: result.nextCursor || null,
|
||
dailyRemaining: result.dailyRemaining ?? this.state.dailyRemaining,
|
||
recState: items.length === 0 ? RECOMMENDATION_STATE.FINISHED : RECOMMENDATION_STATE.LOADED,
|
||
});
|
||
} catch (err) {
|
||
this.setState({ recState: RECOMMENDATION_STATE.ERROR, lastError: err });
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Like 一个用户
|
||
* @param {string} userId
|
||
* @returns {Promise<import('../types').LikeResult>}
|
||
* mutualMatch=true 表示刚刚达成双向匹配,调用方应跳转会话页或弹提示
|
||
*/
|
||
async like(userId) {
|
||
if (this._actionLocks.has(`like:${userId}`)) return;
|
||
this._actionLocks.add(`like:${userId}`);
|
||
try {
|
||
const result = await matchService.like(userId);
|
||
// 从推荐列表移除(无论是否 Mutual,已经做出判断)
|
||
this.setState({
|
||
recommendations: this.state.recommendations.filter((r) => r.userId !== userId),
|
||
});
|
||
return result;
|
||
} finally {
|
||
this._actionLocks.delete(`like:${userId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消喜欢
|
||
* 注意:Mutual 后通常不能直接取消,后端会返回 MATCH_REQUIRED
|
||
*/
|
||
async unlike(userId) {
|
||
if (this._actionLocks.has(`unlike:${userId}`)) return;
|
||
this._actionLocks.add(`unlike:${userId}`);
|
||
try {
|
||
await matchService.unlike(userId);
|
||
// 推荐/匹配列表不变,后端 relationship 变 neutral
|
||
} finally {
|
||
this._actionLocks.delete(`unlike:${userId}`);
|
||
}
|
||
}
|
||
|
||
/** Pass(单向意向,对方不收到通知) */
|
||
async pass(userId) {
|
||
if (this._actionLocks.has(`pass:${userId}`)) return;
|
||
this._actionLocks.add(`pass:${userId}`);
|
||
try {
|
||
await matchService.pass(userId);
|
||
this.setState({
|
||
recommendations: this.state.recommendations.filter((r) => r.userId !== userId),
|
||
});
|
||
} finally {
|
||
this._actionLocks.delete(`pass:${userId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 拉黑用户(Block 优先级最高,AGENTS 第 4 条)
|
||
* 拉黑后:本地从推荐/Mutual 列表移除;调用方应跳离当前用户详情页。
|
||
* 跨 Store 副作用由 stores/index.js 编排:通知 messageStore 标记会话不可发消息。
|
||
*/
|
||
async block(userId, reason) {
|
||
if (this._actionLocks.has(`block:${userId}`)) return;
|
||
this._actionLocks.add(`block:${userId}`);
|
||
try {
|
||
await matchService.block(userId, reason ? { reason } : {});
|
||
this.setState({
|
||
recommendations: this.state.recommendations.filter((r) => r.userId !== userId),
|
||
mutualList: this.state.mutualList.filter(
|
||
(c) => c.targetUserId !== userId && c.conversationId !== userId,
|
||
),
|
||
});
|
||
return { relationStatus: RELATION_STATUS.BLOCKED };
|
||
} finally {
|
||
this._actionLocks.delete(`block:${userId}`);
|
||
}
|
||
}
|
||
|
||
/** 解除拉黑 */
|
||
async unblock(userId) {
|
||
if (this._actionLocks.has(`unblock:${userId}`)) return;
|
||
this._actionLocks.add(`unblock:${userId}`);
|
||
try {
|
||
return await matchService.unblock(userId);
|
||
} finally {
|
||
this._actionLocks.delete(`unblock:${userId}`);
|
||
}
|
||
}
|
||
|
||
/** 拉取 Mutual Matches 列表 */
|
||
async fetchMutualList(params = {}) {
|
||
const result = await matchService.getMutualList(params);
|
||
this.setState({
|
||
mutualList: result.items || [],
|
||
mutualCursor: result.nextCursor || null,
|
||
mutualHasMore: !!result.nextCursor,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/** 加载更多 Mutual Matches */
|
||
async loadMoreMutualList() {
|
||
if (!this.state.mutualCursor || !this.state.mutualHasMore) return;
|
||
const result = await matchService.getMutualList({ cursor: this.state.mutualCursor });
|
||
this.setState({
|
||
mutualList: [...this.state.mutualList, ...(result.items || [])],
|
||
mutualCursor: result.nextCursor || null,
|
||
mutualHasMore: !!result.nextCursor,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 拉取我喜欢的人列表(缘分中心 Tab)
|
||
* AGENTS 第 6 条(隐式):单向意向不泄露——本列表为"我"视角,可查看自己喜欢的人。
|
||
*/
|
||
async fetchLikedList(params = {}) {
|
||
const result = await matchService.getLikedByMe(params);
|
||
this.setState({
|
||
likedList: result.items || [],
|
||
likedCursor: result.nextCursor || null,
|
||
likedHasMore: !!result.nextCursor,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/** 加载更多我喜欢的人 */
|
||
async loadMoreLikedList() {
|
||
if (!this.state.likedCursor || !this.state.likedHasMore) return;
|
||
const result = await matchService.getLikedByMe({ cursor: this.state.likedCursor });
|
||
this.setState({
|
||
likedList: [...this.state.likedList, ...(result.items || [])],
|
||
likedCursor: result.nextCursor || null,
|
||
likedHasMore: !!result.nextCursor,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 拉取喜欢我的人列表(缘分中心 Tab)
|
||
* AGENTS 第 6 条:单向意向不泄露——后端仅返回脱敏字段(昵称/年龄/地区/是否已 Mutual),
|
||
* Store 不做任何二次身份还原。
|
||
*/
|
||
async fetchLikersList(params = {}) {
|
||
const result = await matchService.getLikers(params);
|
||
this.setState({
|
||
likersList: result.items || [],
|
||
likersCursor: result.nextCursor || null,
|
||
likersHasMore: !!result.nextCursor,
|
||
likersCount: result.totalCount ?? this.state.likersCount,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/** 加载更多喜欢我的人 */
|
||
async loadMoreLikersList() {
|
||
if (!this.state.likersCursor || !this.state.likersHasMore) return;
|
||
const result = await matchService.getLikers({ cursor: this.state.likersCursor });
|
||
this.setState({
|
||
likersList: [...this.state.likersList, ...(result.items || [])],
|
||
likersCursor: result.nextCursor || null,
|
||
likersHasMore: !!result.nextCursor,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 拉取黑名单列表(黑名单管理页)
|
||
* 返回字段已脱敏,不含对方手机号/身份证等敏感信息。
|
||
*/
|
||
async fetchBlockedList(params = {}) {
|
||
const result = await matchService.getBlockedList(params);
|
||
this.setState({
|
||
blockedList: this._decorateBlockedList(result.items),
|
||
blockedCursor: result.nextCursor || null,
|
||
blockedHasMore: !!result.nextCursor,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
/** 加载更多黑名单 */
|
||
async loadMoreBlockedList() {
|
||
if (!this.state.blockedCursor || !this.state.blockedHasMore) return;
|
||
const result = await matchService.getBlockedList({ cursor: this.state.blockedCursor });
|
||
this.setState({
|
||
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 });
|
||
this.setState({ likersCount: result.totalCount || 0 });
|
||
return result.totalCount || 0;
|
||
}
|
||
|
||
/** 拉取我喜欢的人计数 */
|
||
async fetchLikedByMeCount() {
|
||
const result = await matchService.getLikedByMe({ pageSize: 1 });
|
||
this.setState({ likedByMeCount: (result.items || []).length });
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 解除匹配(AGENTS 第 4 条:解除后双方会话立即停用)
|
||
* @param {string} matchId
|
||
*/
|
||
async unmatch(matchId) {
|
||
if (this._actionLocks.has(`unmatch:${matchId}`)) return;
|
||
this._actionLocks.add(`unmatch:${matchId}`);
|
||
try {
|
||
await matchService.unmatch(matchId);
|
||
this.setState({
|
||
mutualList: this.state.mutualList.filter((c) => c.conversationId !== matchId),
|
||
});
|
||
} finally {
|
||
this._actionLocks.delete(`unmatch:${matchId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发起成功牵手请求(AGENTS 第 6 条:双确认第一步)
|
||
* @param {string} matchId
|
||
* @returns {Promise<{ successRequestId: string, expiresAt: number, status: string }>}
|
||
*/
|
||
async requestSuccess(matchId) {
|
||
if (this._actionLocks.has(`success-req:${matchId}`)) return;
|
||
this._actionLocks.add(`success-req:${matchId}`);
|
||
try {
|
||
const result = await matchService.requestSuccess(matchId);
|
||
this.setState({
|
||
successPendingMatchIds: [...new Set([...this.state.successPendingMatchIds, matchId])],
|
||
});
|
||
return result;
|
||
} finally {
|
||
this._actionLocks.delete(`success-req:${matchId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确认成功牵手(双确认第二步,仅被请求方调用)
|
||
* @param {string} matchId
|
||
*/
|
||
async confirmSuccess(matchId) {
|
||
if (this._actionLocks.has(`success-confirm:${matchId}`)) return;
|
||
this._actionLocks.add(`success-confirm:${matchId}`);
|
||
try {
|
||
const result = await matchService.confirmSuccess(matchId);
|
||
// 成功后从 pending 列表移除
|
||
this.setState({
|
||
successPendingMatchIds: this.state.successPendingMatchIds.filter((id) => id !== matchId),
|
||
});
|
||
return result;
|
||
} finally {
|
||
this._actionLocks.delete(`success-confirm:${matchId}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const matchStore = new MatchStore();
|
||
module.exports = matchStore;
|