Files
danyang-match/miniapp/stores/matchStore.js
T

385 lines
13 KiB
JavaScript
Raw Normal View History

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');
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({
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({
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;
}
/**
* 黑名单列表装饰
*
* 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;