Files
danyang-match/miniapp/stores/matchStore.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

385 lines
13 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.
/**
* 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;