2026-09-09 22:05:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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');
|
2026-09-15 23:16:27 +08:00
|
|
|
|
const { formatConversationTime } = require('../utils/datetime');
|
2026-09-09 22:05:00 +08:00
|
|
|
|
|
|
|
|
|
|
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({
|
2026-09-15 23:16:27 +08:00
|
|
|
|
blockedList: this._decorateBlockedList(result.items),
|
2026-09-09 22:05:00 +08:00
|
|
|
|
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({
|
2026-09-15 23:16:27 +08:00
|
|
|
|
blockedList: [...this.state.blockedList, ...this._decorateBlockedList(result.items)],
|
2026-09-09 22:05:00 +08:00
|
|
|
|
blockedCursor: result.nextCursor || null,
|
|
|
|
|
|
blockedHasMore: !!result.nextCursor,
|
|
|
|
|
|
});
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 23:16:27 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 黑名单列表装饰
|
|
|
|
|
|
*
|
|
|
|
|
|
* pages/blacklist 模板读取 `item.blockedAtText`,但该字段服务端不产出
|
|
|
|
|
|
* (后端只给 epoch 毫秒的 blockedAt),故进入 store 时统一格式化一次,
|
|
|
|
|
|
* 与 messageStore._decorateConversations 同构,避免每个页面各自实现。
|
|
|
|
|
|
*/
|
|
|
|
|
|
_decorateBlockedList(items) {
|
|
|
|
|
|
return (items || []).map((u) => ({
|
|
|
|
|
|
...u,
|
|
|
|
|
|
blockedAtText: formatConversationTime(u.blockedAt),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 22:05:00 +08:00
|
|
|
|
/** 拉取喜欢我的人计数(不暴露具体身份) */
|
|
|
|
|
|
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;
|