chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
/**
* ActivityStore(规范 §131)
*
* 职责:
* - 维护活动列表(首页 + 我的活动)
* - 维护活动报名状态 / 互选意向(AGENTS 第 6 条:单向意向保密)
* - 维护我报名的活动集合,用于徽标与"我的活动"页
*
* 不变量(AGENTS.md):
* - 第 1 条:用户永久免费——活动 feeType=paid 字段仅作展示,前端不实现任何支付入口。
* - 第 6 条:活动单向意向保密——submitInterests 返回的 matchedUserIds 仅含双向匹配,
* 未匹配的人不在前端可见;Store 不缓存"谁单独选了我"的字段。
*/
const BaseStore = require('./baseStore');
const { activityService } = require('../services');
const { ACTIVITY_STATUS, REGISTRATION_STATUS } = require('../constants');
class ActivityStore extends BaseStore {
constructor() {
super({
/** @type {import('../types').Activity[]} */
activities: [],
activitiesCursor: null,
activitiesHasMore: true,
activitiesLoading: false,
/** @type {import('../types').Activity[]} */
myActivities: [],
myActivitiesCursor: null,
myActivitiesHasMore: true,
myActivitiesLoading: false,
/** @type {Record<string, import('../types').Activity>} 活动详情缓存 */
detailCache: {},
/** @type {Record<string, import('../types').RegistrationStatus>} activityId -> 报名状态 */
registrationStatusByActivity: {},
/** 我报名成功且未结束的活动数(徽标用) */
myActiveCount: 0,
lastError: null,
});
}
/** 拉取活动列表(首页) */
async fetchActivities(params = {}) {
if (this.state.activitiesLoading) return;
this.setState({ activitiesLoading: true, lastError: null });
try {
const result = await activityService.getActivities({
status: ACTIVITY_STATUS.REGISTRATION_OPEN,
...params,
});
this.setState({
activities: result.items || [],
activitiesCursor: result.nextCursor || null,
activitiesHasMore: !!result.nextCursor,
activitiesLoading: false,
});
return result;
} catch (err) {
this.setState({ activitiesLoading: false, lastError: err });
throw err;
}
}
/** 加载更多活动 */
async loadMoreActivities() {
if (this.state.activitiesLoading || !this.state.activitiesCursor) return;
this.setState({ activitiesLoading: true });
try {
const result = await activityService.getActivities({
cursor: this.state.activitiesCursor,
pageSize: 20,
});
this.setState({
activities: [...this.state.activities, ...(result.items || [])],
activitiesCursor: result.nextCursor || null,
activitiesHasMore: !!result.nextCursor,
activitiesLoading: false,
});
} catch (err) {
this.setState({ activitiesLoading: false, lastError: err });
throw err;
}
}
/** 拉取我的活动 */
async fetchMyActivities(params = {}) {
if (this.state.myActivitiesLoading) return;
this.setState({ myActivitiesLoading: true, lastError: null });
try {
const result = await activityService.getMyActivities(params);
const items = result.items || [];
this.setState({
myActivities: items,
myActivitiesCursor: result.nextCursor || null,
myActivitiesHasMore: !!result.nextCursor,
myActivitiesLoading: false,
myActiveCount: items.filter(
(a) =>
a.registrationStatus === REGISTRATION_STATUS.APPROVED &&
a.status !== ACTIVITY_STATUS.ENDED &&
a.status !== ACTIVITY_STATUS.CANCELLED,
).length,
});
return result;
} catch (err) {
this.setState({ myActivitiesLoading: false, lastError: err });
throw err;
}
}
/** 加载更多我的活动 */
async loadMoreMyActivities() {
if (this.state.myActivitiesLoading || !this.state.myActivitiesCursor) return;
this.setState({ myActivitiesLoading: true });
try {
const result = await activityService.getMyActivities({
cursor: this.state.myActivitiesCursor,
pageSize: 20,
});
this.setState({
myActivities: [...this.state.myActivities, ...(result.items || [])],
myActivitiesCursor: result.nextCursor || null,
myActivitiesHasMore: !!result.nextCursor,
myActivitiesLoading: false,
});
} catch (err) {
this.setState({ myActivitiesLoading: false, lastError: err });
throw err;
}
}
/** 拉取活动详情(优先取缓存) */
async fetchActivityDetail(activityId, { force = false } = {}) {
if (!force && this.state.detailCache[activityId]) {
return this.state.detailCache[activityId];
}
const detail = await activityService.getActivityDetail(activityId);
this.setState({
detailCache: { ...this.state.detailCache, [activityId]: detail },
registrationStatusByActivity: {
...this.state.registrationStatusByActivity,
[activityId]: detail.registrationStatus,
},
});
return detail;
}
/**
* 报名活动
* @param {string} activityId
* @param {{ remark?: string }} [payload]
*/
async register(activityId, payload) {
const result = await activityService.register(activityId, payload);
this.setState({
registrationStatusByActivity: {
...this.state.registrationStatusByActivity,
[activityId]: REGISTRATION_STATUS.PENDING,
},
});
return result;
}
/**
* 提交活动互选意向(AGENTS 第 6 条:单向意向保密)
* @param {string} activityId
* @param {string[]} likedUserIds
* @returns {Promise<{ matchedUserIds: string[] }>} 仅返回双向匹配的 userIds
*/
async submitInterests(activityId, likedUserIds) {
return activityService.submitInterests(activityId, { likedUserIds });
}
/** 提交活动反馈 */
async submitFeedback(activityId, payload) {
return activityService.submitFeedback(activityId, payload);
}
}
const activityStore = new ActivityStore();
module.exports = activityStore;
+150
View File
@@ -0,0 +1,150 @@
/**
* AppStore(规范 §131)
*
* 职责:
* - 维护运行时业务配置(来自服务端,覆盖 BUSINESS_LIMITS 默认值)
* - 维护平台维护态(503 时由 apiClient 注入)
* - 维护网络状态(断网/弱网提示)
* - 提供 toast / loading / modal 统一封装,避免页面散落 wx.* UI 调用
*
* 不变量(AGENTS.md 第 9 条):PostgreSQL 是 Source of Truth——
* 本 Store 持有的 businessConfig 仅是服务端配置的运行时缓存,
* 任何前端缓存的配置都不能覆盖后端校验。
*/
const BaseStore = require('./baseStore');
const api = require('../utils/api');
const { appConfigService } = require('../services');
const { BUSINESS_LIMITS, mapErrorMessage } = require('../constants');
class AppStore extends BaseStore {
constructor() {
super({
/** @type {typeof BUSINESS_LIMITS} 运行时合并后的业务配置 */
businessConfig: { ...BUSINESS_LIMITS },
/** @type {boolean} 平台维护态 */
maintenance: false,
maintenanceMessage: '',
/** @type {boolean} 网络在线 */
networkOnline: true,
/** @type {boolean} 全局 loading 显示中 */
loadingVisible: false,
loadingText: '',
/** @type {boolean} 新手引导是否已关闭(按 storage 记忆) */
newbieTipsClosed: false,
});
// 注入维护态拦截回调(apiClient 503 时调用)
api.onMaintenance = (config) => this.handleMaintenance(config);
// 监听网络状态
if (typeof wx !== 'undefined' && wx.onNetworkStatusChange) {
wx.onNetworkStatusChange((res) => {
this.setState({ networkOnline: !!res.isConnected });
});
// 启动时同步一次当前网络状态
if (wx.getNetworkType) {
wx.getNetworkType({
success: (res) => {
this.setState({ networkOnline: res.networkType !== 'none' });
},
});
}
}
}
/** 冷启动初始化:拉取服务端配置覆盖默认值(规范 §118) */
async init() {
try {
const config = await appConfigService.fetchAppConfig();
this.setState({ businessConfig: config });
} catch (e) {
// 接口失败时使用默认值(规范 §119),不阻塞启动
this.setState({ businessConfig: { ...BUSINESS_LIMITS } });
}
}
/** 503 维护态进入:弹维护提示,后续请求会被 apiClient 自动拦截 */
handleMaintenance(config = {}) {
this.setState({
maintenance: true,
maintenanceMessage: config.message || '平台正在维护,请稍后再试',
});
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: this.state.maintenanceMessage,
icon: 'none',
duration: 3000,
});
}
}
/** 清除维护态(一般由刷新页面后重新拉取 /me/status 时恢复) */
clearMaintenance() {
this.setState({ maintenance: false, maintenanceMessage: '' });
}
/**
* 统一 Toast(规范 §56:1.5~2 秒)
* @param {string} message
* @param {'success'|'error'|'warning'|'none'} [type='none']
*/
toast(message, type = 'none') {
if (typeof wx === 'undefined' || !wx.showToast) return;
const iconMap = { success: 'success', error: 'error', warning: 'none', none: 'none' };
wx.showToast({
title: message || '',
icon: iconMap[type] || 'none',
duration: this.state.businessConfig.TOAST_DURATION || BUSINESS_LIMITS.TOAST_DURATION,
});
}
/** 成功提示 */
toastSuccess(message) { this.toast(message, 'success'); }
/** 错误提示(自动从 err 中提取用户可理解文案,规范 §80-84) */
toastError(err, fallback = '操作失败,请稍后再试') {
const code = err && err.code;
const message = code ? mapErrorMessage(code, fallback) : (err && err.message) || fallback;
this.toast(message, 'none');
}
/** 全局 loading */
showLoading(text = '加载中...') {
if (this.state.loadingVisible) return;
this.setState({ loadingVisible: true, loadingText: text });
if (typeof wx !== 'undefined' && wx.showLoading) {
wx.showLoading({ title: text, mask: true });
}
}
hideLoading() {
if (!this.state.loadingVisible) return;
this.setState({ loadingVisible: false, loadingText: '' });
if (typeof wx !== 'undefined' && wx.hideLoading) wx.hideLoading();
}
/**
* 统一确认对话框(用于危险操作:拉黑/解除匹配/暂停寻找/成功牵手确认)
* @param {{ title?: string, content: string, confirmText?: string, cancelText?: string, danger?: boolean }} opts
* @returns {Promise<boolean>} 用户是否确认
*/
confirm(opts) {
return new Promise((resolve) => {
if (typeof wx === 'undefined' || !wx.showModal) return resolve(false);
wx.showModal({
title: opts.title || (opts.danger ? '请确认' : '提示'),
content: opts.content,
confirmText: opts.confirmText || '确认',
cancelText: opts.cancelText || '取消',
confirmColor: opts.danger ? '#E04C4C' : '#1A6B3E',
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
}
}
const appStore = new AppStore();
module.exports = appStore;
+159
View File
@@ -0,0 +1,159 @@
/**
* AuthStore(规范 §131)
*
* 职责:
* - 微信登录 / 绑定手机号 / 刷新 Token / 登出
* - Token 持久化委托 utils/api.js 完成;本层只维护 isLoggedIn 等状态
* - 401 Session 失效时单飞刷新,刷新失败再触发 onSessionExpired
* - 注入 onSessionExpired 回调到 apiClient
*
* 不变量(AGENTS.md 第 3 条):未登录用户不可访问任何需要权限的页面,
* 路由守卫根据 AuthStore.isLoggedIn 决定是否重定向到登录页。
*/
const BaseStore = require('./baseStore');
const api = require('../utils/api');
const { authService } = require('../services');
const { STORAGE_KEYS, REQUEST_ERROR } = require('../constants');
class AuthStore extends BaseStore {
constructor() {
super({
isLoggedIn: false,
/** @type {string|null} */
userId: null,
/** @type {string|null} */
phoneBound: false,
/** @type {'idle'|'logging_in'|'refreshing'|'logging_out'|'expired'} */
authStatus: 'idle',
lastError: null,
});
this._refreshPromise = null;
// 注入 Session 失效回调(apiClient 在 401 时调用)
api.onSessionExpired = () => this.handleSessionExpired();
}
/** 冷启动从 storage 还原登录态 */
hydrate() {
const snapshot = BaseStore.hydrate(STORAGE_KEYS.IS_LOGGED_IN);
if (snapshot && typeof snapshot === 'object') {
this.setState({
isLoggedIn: !!snapshot.isLoggedIn,
userId: snapshot.userId || null,
phoneBound: !!snapshot.phoneBound,
});
} else if (api.getToken()) {
// 兼容旧版仅存了 token 的场景
this.setState({ isLoggedIn: true });
}
}
/**
* 微信登录
* @param {string} code wx.login() 拿到的 code
* @returns {Promise<{ needPhone: boolean, needProfile: boolean }>}
*/
async loginWithWechat(code) {
this.setState({ authStatus: 'logging_in', lastError: null });
try {
const result = await authService.wechatLogin({ code });
api.saveToken(result.token, result.refreshToken);
const patch = {
isLoggedIn: true,
userId: result.userId || null,
phoneBound: !result.needPhone,
authStatus: 'idle',
};
this.setState(patch);
this.persist(
{ isLoggedIn: true, userId: patch.userId, phoneBound: patch.phoneBound },
STORAGE_KEYS.IS_LOGGED_IN,
);
return { needPhone: !!result.needPhone, needProfile: !!result.needProfile };
} catch (err) {
this.setState({ authStatus: 'idle', lastError: err });
throw err;
}
}
/**
* 绑定手机号
* @param {{ phoneCode?: string, encryptedData?: string, iv?: string }} payload
*/
async bindPhone(payload) {
const result = await authService.bindPhone(payload);
this.setState({ phoneBound: true });
// 同步持久化
const snap = BaseStore.hydrate(STORAGE_KEYS.IS_LOGGED_IN) || {};
this.persist({ ...snap, phoneBound: true }, STORAGE_KEYS.IS_LOGGED_IN);
return result;
}
/**
* 刷新 Token(单飞:并发 401 只触发一次刷新)
* @returns {Promise<boolean>} 是否刷新成功
*/
async refresh() {
if (this._refreshPromise) return this._refreshPromise;
this._refreshPromise = (async () => {
this.setState({ authStatus: 'refreshing' });
try {
const result = await authService.refresh();
api.saveToken(result.token, result.refreshToken);
this.setState({ authStatus: 'idle', isLoggedIn: true });
return true;
} catch (err) {
// 刷新失败 → 真正的 Session 失效
this._handleExpired();
return false;
} finally {
this._refreshPromise = null;
}
})();
return this._refreshPromise;
}
/** 401 时由 apiClient 调用:先尝试刷新,刷新失败再走 onSessionExpired */
async handleSessionExpired() {
// 已在登录流程中则不打断
if (this.state.authStatus === 'logging_in') return;
const ok = await this.refresh();
if (!ok) {
this._handleExpired();
}
}
/** 真正的 Session 失效:清 token、清状态、由路由守卫跳登录 */
_handleExpired() {
api.clearToken();
try { wx.removeStorageSync(STORAGE_KEYS.IS_LOGGED_IN); } catch (e) {}
this.setState({
isLoggedIn: false,
userId: null,
phoneBound: false,
authStatus: 'expired',
lastError: { code: REQUEST_ERROR.UNAUTHORIZED, message: '登录状态已过期,请重新登录' },
});
}
/** 主动登出 */
async logout() {
this.setState({ authStatus: 'logging_out' });
try {
await authService.logout();
} catch (e) {
// 后端登出失败不阻塞本地登出
}
api.clearToken();
try { wx.removeStorageSync(STORAGE_KEYS.IS_LOGGED_IN); } catch (e) {}
this.setState({
isLoggedIn: false,
userId: null,
phoneBound: false,
authStatus: 'idle',
lastError: null,
});
}
}
const authStore = new AuthStore();
module.exports = authStore;
+104
View File
@@ -0,0 +1,104 @@
/**
* Store 基类(规范 §131)
*
* 微信小程序原生开发无 Pinia/MobX,此处用极简的发布订阅实现响应式 Store:
* - state:只读数据视图
* - setState(partial):浅合并 + 通知订阅者
* - subscribe(listener):页面 onLoad/onShow 订阅,onUnload/onHide 退订
*
* 设计原则:
* 1. Store 是单例,跨页面共享。
* 2. Store 只持有"跨页面需要"的状态;纯页面局部状态仍用 this.data + setData。
* 3. Store 内部不直接操作 wx.* UI(toast/modal),UI 副作用由调用方页面承担;
* 但 AppStore 提供 toast/loading 的统一封装供页面调用。
* 4. Store 的 action 返回 Promise,错误由调用方 try/catch 并决定 UI 反馈。
*/
const { STORAGE_KEYS } = require('../constants');
class BaseStore {
constructor(initialState = {}) {
this.state = { ...initialState };
/** @type {Array<(state: object, patch: object) => void>} */
this._listeners = [];
}
/**
* 浅合并 patch 到 state,并通知所有订阅者
* @param {Partial<typeof this.state>} patch
* @returns {object} 合并后的新 state(引用变化)
*/
setState(patch) {
const next = { ...this.state, ...patch };
this.state = next;
for (const fn of this._listeners) {
try { fn(next, patch); } catch (e) { /* 单个监听者错误不影响其他 */ }
}
return next;
}
/**
* 订阅 state 变更,返回退订函数
* @param {(state: object, patch: object) => void} listener
* @returns {() => void} unsubscribe
*/
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
this._listeners.push(listener);
return () => {
const i = this._listeners.indexOf(listener);
if (i >= 0) this._listeners.splice(i, 1);
};
}
/**
* 把当前 state 的指定字段同步到页面 data(页面常用辅助)
* @param {WechatMiniprogram.Page.Instance} page
* @param {{ [storeKey: string]: string }} mapping storeKey -> pageDataKey
* @returns {() => void} 退订函数
*/
bindToPage(page, mapping = {}) {
const apply = () => {
const patch = {};
for (const storeKey of Object.keys(mapping)) {
const pageKey = mapping[storeKey] || storeKey;
// 跳过 undefined 值,避免触发"Setting data field X to undefined is invalid"警告
const value = this.state[storeKey];
if (value !== undefined) {
patch[pageKey] = value;
}
}
if (Object.keys(patch).length > 0 && typeof page.setData === 'function') {
page.setData(patch);
}
};
apply(); // 立即同步一次
return this.subscribe(apply);
}
/**
* 把关键字段持久化到 wx.storage(避免刷新/冷启动丢失)
* @param {object} snapshot
* @param {string} storageKey STORAGE_KEYS 中的键
*/
persist(snapshot, storageKey) {
try {
wx.setStorageSync(storageKey, snapshot);
} catch (e) { /* ignore */ }
}
/**
* 从 wx.storage 还原(冷启动调用)
* @param {string} storageKey STORAGE_KEYS 中的键
* @returns {object|null}
*/
static hydrate(storageKey) {
try {
const v = wx.getStorageSync(storageKey);
return v && typeof v === 'object' ? v : null;
} catch (e) {
return null;
}
}
}
module.exports = BaseStore;
+238
View File
@@ -0,0 +1,238 @@
/**
* stores 聚合入口(规范 §131)
*
* 暴露 7 个 Store 单例 + 跨 Store 编排辅助函数 + 启动初始化 bootstrap。
*
* 跨 Store 编排原则:
* - 单个 Store 自身只维护自己的状态,不直接 require 其他 Store(避免循环依赖)。
* - 涉及多 Store 副作用的动作(如 Block 后会话停用、Mutual 后会话刷新)通过本文件
* 暴露的辅助函数编排,调用方(页面)使用这些辅助函数而非直接组合多个 Store。
*
* 用法(页面):
* const { matchStore, appStore, blockUser } = require('../../stores');
* await blockUser(userId, '骚扰');
*/
const authStore = require('./authStore');
const userStore = require('./userStore');
const matchStore = require('./matchStore');
const messageStore = require('./messageStore');
const activityStore = require('./activityStore');
const matchmakerStore = require('./matchmakerStore');
const appStore = require('./appStore');
const { ROUTE, PROFILE_REVIEW, ACCOUNT_STATUS, buildPath } = require('../constants');
// ============ Review 状态路由守卫(P05-T12,规范 §4.3) ============
//
// AGENTS 第 9 条:PostgreSQL 是 Source of Truth——守卫只读 userStore.state.userState,
// 不预判、不缓存;状态变化时由各页 onShow 调用 requireApproved 自检并跳转。
//
// 受保护路由分级:
// - 'approved':资料审核通过后方可访问(推荐/找对象/缘分中心/用户详情/活动报名)
// - 'searching':关系态为 searching 方可访问(部分推荐入口)
// - 'logged_in':仅需登录(消息/我的/设置等)
// 审核未通过时一律引导至 REVIEW.PENDING 或 REVIEW.REJECTED。
/**
* 受保护路由 → 所需最低审核态映射
* 未列出的路由默认为 'logged_in'(仅需登录)
*/
const ROUTE_ACCESS_REQUIREMENTS = {
// 推荐 / 找对象(PROFILE_NOT_APPROVED 错误码兜底,但客户端预拦截体验更好)
[ROUTE.TAB.DISCOVER]: 'approved',
[ROUTE.MAIN.DISCOVER]: 'approved',
[ROUTE.MAIN.FATE_CENTER]: 'approved',
[ROUTE.DETAIL.PROFILE_DETAIL]: 'approved',
[ROUTE.DETAIL.ACTIVITY_REGISTER]: 'approved',
// 红娘申请需审核通过
[ROUTE.DETAIL.MATCHMAKER_APPLY]: 'approved',
};
/**
* 读取当前用户的审核态(来自 userState.profileReviewStatus,仅本地读取)
* @returns {string} PROFILE_REVIEW 值;未登录或无状态时返回 NOT_SUBMITTED
*/
function readReviewStatus() {
return userStore.state.userState?.profileReviewStatus || PROFILE_REVIEW.NOT_SUBMITTED;
}
/**
* 根据当前审核态决定应跳转的"审核态页面"
* @returns {string|null} 需要跳转的路径;approved 返回 null
*/
function getReviewRedirectPath() {
const status = readReviewStatus();
if (status === PROFILE_REVIEW.PENDING) return ROUTE.REVIEW.PENDING;
if (status === PROFILE_REVIEW.NEED_EDIT || status === PROFILE_REVIEW.REJECTED) {
return ROUTE.REVIEW.REJECTED;
}
return null;
}
/**
* 校验当前用户是否可访问指定路径
* @param {string} path 路由常量
* @returns {{ok: boolean, redirect?: string}} ok=true 可访问;ok=false 时 redirect 为应跳转路径
*/
function canAccess(path) {
const required = ROUTE_ACCESS_REQUIREMENTS[path];
if (!required || required === 'logged_in') return { ok: true };
const reviewStatus = readReviewStatus();
const accountStatus = userStore.state.userState?.accountStatus || ACCOUNT_STATUS.NORMAL;
// 账号异常态优先拦截
if (accountStatus !== ACCOUNT_STATUS.NORMAL) {
return { ok: false, redirect: ROUTE.REVIEW.ACCOUNT_STATUS };
}
if (required === 'approved' && reviewStatus !== PROFILE_REVIEW.APPROVED) {
const redirect = getReviewRedirectPath();
return { ok: false, redirect: redirect || ROUTE.REVIEW.PENDING };
}
return { ok: true };
}
/**
* 守卫式导航:检查审核态后再决定跳转目标
*
* 用法:
* const { guardedNavigate } = require('../../stores');
* guardedNavigate(ROUTE.MAIN.DISCOVER); // 未审核则自动跳 review-pending
*
* @param {string} path 目标路由常量
* @param {Record<string, string|number>} [params] 仅 id 类参数
* @returns {boolean} 是否成功发起导航(true=已跳转到目标或审核页)
*/
function guardedNavigate(path, params) {
const access = canAccess(path);
if (access.ok) {
const target = params ? buildPath(path, params) : path;
wx.navigateTo({ url: target });
return true;
}
if (access.redirect) {
wx.redirectTo({ url: access.redirect });
}
return false;
}
/**
* 当前用户是否已通过资料审核
* @returns {boolean}
*/
function isProfileApproved() {
return readReviewStatus() === PROFILE_REVIEW.APPROVED;
}
// ============ 跨 Store 编排 ============
/**
* 拉黑用户:触发 MatchStore 与 MessageStore 同步副作用
* - MatchStore:从推荐/Mutual 列表移除该用户
* - MessageStore:标记对应会话不可发消息(AGENTS 第 4 条)
* @param {string} userId
* @param {string} [reason]
*/
async function blockUser(userId, reason) {
await matchStore.block(userId, reason);
// 查找对应会话并标记停用
const conv = messageStore.state.conversations.find(
(c) => c.targetUserId === userId || c.conversationId === userId,
);
if (conv) messageStore.markConversationBlocked(conv.conversationId);
}
/**
* Like 用户:若返回 mutualMatch=true,自动刷新会话列表
* @param {string} userId
* @returns {Promise<import('../types').LikeResult>}
*/
async function likeUser(userId) {
const result = await matchStore.like(userId);
if (result && result.mutualMatch) {
// Mutual Match 达成,刷新会话列表,让用户能立即开始聊天
await messageStore.fetchConversations();
}
return result;
}
/**
* 解除匹配:触发 MatchStore 与 MessageStore 同步副作用
* @param {string} matchId
*/
async function unmatchConversation(matchId) {
await matchStore.unmatch(matchId);
messageStore.markConversationBlocked(matchId);
}
/**
* 成功牵手:双确认第二步(仅被请求方调用),成功后刷新 userStore 与会话列表
* @param {string} matchId
*/
async function confirmSuccess(matchId) {
const result = await matchStore.confirmSuccess(matchId);
// 成功牵手后关系状态变化,需刷新 userState
try { await userStore.fetchStatus(); } catch (e) { /* 不阻塞主流程 */ }
try { await messageStore.fetchConversations(); } catch (e) { /* 同上 */ }
return result;
}
// ============ 启动初始化 ============
/**
* App.onLaunch 调用,按依赖顺序初始化所有 Store
* 1. appStore.init() — 拉取服务端配置(不阻塞核心)
* 2. authStore.hydrate() — 从本地恢复登录态
* 3. userStore.hydrate() — 从本地恢复用户状态快照
* 4. messageStore.hydrate() — 从本地恢复未读数(兜底)
* 5. 若已登录,异步刷新 userStore.fetchStatus()(不阻塞启动)
*/
async function bootstrap() {
// 同步阶段:本地状态恢复(避免白屏)
authStore.hydrate();
userStore.hydrate();
messageStore.hydrate();
// 异步阶段:服务端配置 + 用户状态刷新
// 配置失败不阻塞启动;状态失败由路由守卫处理(跳登录页)
appStore.init().catch(() => {});
if (authStore.state.isLoggedIn) {
userStore.fetchStatus().catch(() => {});
} else {
// 未登录:跳转到登录页(使用 navigateTo 而非 redirectTo,因为首页是 Tab 页)
setTimeout(() => {
try {
wx.navigateTo({ url: ROUTE.AUTH.LOGIN });
} catch (e) {
console.warn('[bootstrap] 跳转登录页失败:', e);
}
}, 300);
}
}
module.exports = {
// 单例 Store
authStore,
userStore,
matchStore,
messageStore,
activityStore,
matchmakerStore,
appStore,
// 跨 Store 编排辅助
blockUser,
likeUser,
unmatchConversation,
confirmSuccess,
// Review 状态路由守卫(P05-T12)
readReviewStatus,
getReviewRedirectPath,
canAccess,
guardedNavigate,
isProfileApproved,
ROUTE_ACCESS_REQUIREMENTS,
// 启动入口
bootstrap,
};
+369
View File
@@ -0,0 +1,369 @@
/**
* 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');
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: 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, ...(result.items || [])],
blockedCursor: result.nextCursor || null,
blockedHasMore: !!result.nextCursor,
});
return result;
}
/** 拉取喜欢我的人计数(不暴露具体身份) */
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;
+156
View File
@@ -0,0 +1,156 @@
/**
* MatchmakerStore(规范 §131)
*
* 职责:
* - 维护我申请的红娘服务列表
* - 维护红娘推荐列表(仅展示,最终 Like/Pass 走 matchStore)
* - 维护当前进行中的服务详情
*
* 不变量(AGENTS.md):
* - 第 5 条:红娘不可替用户 Like——本 Store 不暴露任何代用户 Like/Pass 的方法,
* 用户对红娘推荐做出选择时仍走 matchStore.like/pass(userId)。
* - 第 7 条:普通工作人员不能查看私人聊天——本 Store 无聊天接入。
* - 第 1 条:用户永久免费——红娘服务不引入任何"高级红娘/付费红娘"差异化。
*/
const BaseStore = require('./baseStore');
const { matchmakerService } = require('../services');
const { MATCHMAKER_SERVICE_STATUS } = require('../constants');
class MatchmakerStore extends BaseStore {
constructor() {
super({
/** @type {import('../types').MatchmakerService[]} */
myServices: [],
servicesCursor: null,
servicesHasMore: true,
servicesLoading: false,
/** 当前进行中的服务(status != completed/cancelled) */
activeService: null,
/** @type {import('../types').MatchmakerRecommendation[]} */
recommendations: [],
recommendationsCursor: null,
recommendationsHasMore: true,
recommendationsLoading: false,
lastError: null,
});
}
/** 拉取我的红娘服务列表 */
async fetchMyServices(params = {}) {
if (this.state.servicesLoading) return;
this.setState({ servicesLoading: true, lastError: null });
try {
const result = await matchmakerService.getMyServices(params);
const items = result.items || [];
const active = items.find(
(s) =>
s.status !== MATCHMAKER_SERVICE_STATUS.COMPLETED &&
s.status !== MATCHMAKER_SERVICE_STATUS.CANCELLED,
) || null;
this.setState({
myServices: items,
servicesCursor: result.nextCursor || null,
servicesHasMore: !!result.nextCursor,
servicesLoading: false,
activeService: active,
});
return result;
} catch (err) {
this.setState({ servicesLoading: false, lastError: err });
throw err;
}
}
/** 加载更多服务 */
async loadMoreServices() {
if (this.state.servicesLoading || !this.state.servicesCursor) return;
this.setState({ servicesLoading: true });
try {
const result = await matchmakerService.getMyServices({
cursor: this.state.servicesCursor,
pageSize: 20,
});
this.setState({
myServices: [...this.state.myServices, ...(result.items || [])],
servicesCursor: result.nextCursor || null,
servicesHasMore: !!result.nextCursor,
servicesLoading: false,
});
} catch (err) {
this.setState({ servicesLoading: false, lastError: err });
throw err;
}
}
/** 申请红娘服务 */
async applyService(payload) {
const result = await matchmakerService.applyService(payload);
// 重新拉取列表以反映最新状态
await this.fetchMyServices();
return result;
}
/** 拉取服务详情 */
async fetchServiceDetail(serviceId) {
return matchmakerService.getServiceDetail(serviceId);
}
/** 拉取红娘推荐列表 */
async fetchRecommendations(params = {}) {
if (this.state.recommendationsLoading) return;
this.setState({ recommendationsLoading: true, lastError: null });
try {
const result = await matchmakerService.getRecommendations(params);
this.setState({
recommendations: result.items || [],
recommendationsCursor: result.nextCursor || null,
recommendationsHasMore: !!result.nextCursor,
recommendationsLoading: false,
});
return result;
} catch (err) {
this.setState({ recommendationsLoading: false, lastError: err });
throw err;
}
}
/** 加载更多红娘推荐 */
async loadMoreRecommendations() {
if (this.state.recommendationsLoading || !this.state.recommendationsCursor) return;
this.setState({ recommendationsLoading: true });
try {
const result = await matchmakerService.getRecommendations({
cursor: this.state.recommendationsCursor,
pageSize: 20,
});
this.setState({
recommendations: [...this.state.recommendations, ...(result.items || [])],
recommendationsCursor: result.nextCursor || null,
recommendationsHasMore: !!result.nextCursor,
recommendationsLoading: false,
});
} catch (err) {
this.setState({ recommendationsLoading: false, lastError: err });
throw err;
}
}
/**
* 对红娘推荐的用户表达 Like/Pass(实际走 matchStore)
* 调用方应直接调用 matchStore.like/pass(userId),本方法仅为统一入口的便利封装。
* 注意:本 Store 自身不替用户做决定,仅做接口转发(AGENTS 第 5 条)。
*/
async likeRecommendation(userId, matchStore) {
return matchStore.like(userId);
}
async passRecommendation(userId, matchStore) {
return matchStore.pass(userId);
}
}
const matchmakerStore = new MatchmakerStore();
module.exports = matchmakerStore;
+396
View File
@@ -0,0 +1,396 @@
/**
* MessageStore(规范 §131)
*
* 职责:
* - 维护会话列表(Mutual Matches)
* - 维护当前打开会话的消息历史(按 conversationId 索引)
* - 维护总未读数(驱动 Tab 徽标)
* - 发送消息(本地先入队、失败重试)
*
* 不变量(AGENTS.md):
* - 第 3 条:Mutual Match 后才能聊天——send 失败时若返回 MATCH_REQUIRED,
* UI 引导回推荐页;本 Store 不在客户端做硬阻断,由后端守门。
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false,send 直接 reject,
* UI 显示"会话已停用"提示。
* - 第 7 条:普通工作人员不能查看私人聊天——本 Store 仅服务于用户端。
* - 第 9 条:PostgreSQL 是 Source of Truth——本地仅缓存最近活跃会话的最近
* N 条消息用于即时展示,不做完整离线持久化;冷启动从服务端拉取。
*/
const BaseStore = require('./baseStore');
const { messageService } = require('../services');
const { STORAGE_KEYS, MESSAGE_SEND_STATUS, CHAT_CONNECTION } = require('../constants');
/** 单会话内存缓存上限(避免无限增长) */
const PER_CONVERSATION_CACHE = 30;
class MessageStore extends BaseStore {
constructor() {
super({
/** @type {import('../types').Conversation[]} */
conversations: [],
conversationsCursor: null,
conversationsHasMore: true,
conversationsLoading: false,
/** 当前打开的会话 ID */
activeConversationId: null,
/** @type {Record<string, import('../types').Message[]>} 按 conversationId 索引的内存缓存 */
messagesByConversation: {},
/** @type {Record<string, string|null>} 游标 */
cursorsByConversation: {},
hasMoreByConversation: {},
totalUnreadCount: 0,
/** @type {CHAT_CONNECTION[keyof CHAT_CONNECTION]} WebSocket 连接状态 */
connectionState: CHAT_CONNECTION.DISCONNECTED,
lastError: null,
});
/** @type {Map<string, (msg: import('../types').Message) => void>} 收到服务端推送的监听器,按 conversationId 索引 */
this._realtimeListeners = new Map();
/** @type {Array<(count: number) => void>} 总未读数变化监听 */
this._unreadListeners = [];
}
/** 冷启动从 storage 还原总未读数(仅作兜底,真值以服务端为准) */
hydrate() {
const snap = BaseStore.hydrate(STORAGE_KEYS.TOTAL_UNREAD_COUNT);
if (typeof snap === 'number') this.setState({ totalUnreadCount: snap });
}
/** 拉取会话列表 */
async fetchConversations() {
if (this.state.conversationsLoading) return;
this.setState({ conversationsLoading: true, lastError: null });
try {
const result = await messageService.getConversations({ pageSize: 20 });
this.setState({
conversations: result.items || [],
conversationsCursor: result.nextCursor || null,
conversationsHasMore: !!result.nextCursor,
conversationsLoading: false,
});
await this.refreshTotalUnread();
return result;
} catch (err) {
this.setState({ conversationsLoading: false, lastError: err });
throw err;
}
}
/** 加载更多会话 */
async loadMoreConversations() {
if (this.state.conversationsLoading || !this.state.conversationsCursor) return;
this.setState({ conversationsLoading: true });
try {
const result = await messageService.getConversations({
cursor: this.state.conversationsCursor,
pageSize: 20,
});
this.setState({
conversations: [...this.state.conversations, ...(result.items || [])],
conversationsCursor: result.nextCursor || null,
conversationsHasMore: !!result.nextCursor,
conversationsLoading: false,
});
} catch (err) {
this.setState({ conversationsLoading: false, lastError: err });
throw err;
}
}
/** 打开会话:拉取最近消息 */
async openConversation(conversationId) {
this.setState({ activeConversationId: conversationId });
if (!this.state.messagesByConversation[conversationId]) {
await this.fetchMessages(conversationId);
}
// 打开会话时清除该会话未读
await this.markConversationRead(conversationId);
}
/** 关闭会话 */
closeConversation() {
this.setState({ activeConversationId: null });
}
/** 拉取消息历史(最新一页) */
async fetchMessages(conversationId) {
try {
const result = await messageService.getMessages(conversationId, { pageSize: 20 });
const items = result.items || [];
this.setState({
messagesByConversation: {
...this.state.messagesByConversation,
[conversationId]: items,
},
cursorsByConversation: {
...this.state.cursorsByConversation,
[conversationId]: result.nextCursor || null,
},
hasMoreByConversation: {
...this.state.hasMoreByConversation,
[conversationId]: !!result.nextCursor,
},
});
return items;
} catch (err) {
this.setState({ lastError: err });
throw err;
}
}
/** 加载更早的消息 */
async loadMoreMessages(conversationId) {
const cursor = this.state.cursorsByConversation[conversationId];
if (!cursor) return;
try {
const result = await messageService.getMessages(conversationId, {
cursor,
pageSize: 20,
});
const items = result.items || [];
const prev = this.state.messagesByConversation[conversationId] || [];
this.setState({
messagesByConversation: {
...this.state.messagesByConversation,
[conversationId]: [...items, ...prev].slice(-PER_CONVERSATION_CACHE * 2),
},
cursorsByConversation: {
...this.state.cursorsByConversation,
[conversationId]: result.nextCursor || null,
},
hasMoreByConversation: {
...this.state.hasMoreByConversation,
[conversationId]: !!result.nextCursor,
},
});
return items;
} catch (err) {
this.setState({ lastError: err });
throw err;
}
}
/**
* 发送消息(先入队、乐观更新,失败重试一次后标 failed)
* @param {string} conversationId
* @param {string} content
* @returns {Promise<import('../types').Message>}
*/
async sendMessage(conversationId, content) {
const messageId = 'local-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const clientSentAt = Date.now();
const optimistic = {
messageId,
conversationId,
senderId: 'me',
bubbleType: 'self',
content,
sentAt: clientSentAt,
sendStatus: MESSAGE_SEND_STATUS.SENDING,
};
this._appendMessage(conversationId, optimistic);
try {
const result = await messageService.sendMessage(conversationId, {
content,
messageId,
clientSentAt,
});
const confirmed = {
...optimistic,
messageId: result.messageId || messageId,
sentAt: result.sentAt || clientSentAt,
sendStatus: MESSAGE_SEND_STATUS.SENT,
};
this._replaceMessage(conversationId, messageId, confirmed);
// 更新会话列表里 lastMessage
this._patchConversationLastMessage(conversationId, content, confirmed.sentAt);
return confirmed;
} catch (err) {
// 标记失败,用户可手动重试
this._patchMessage(conversationId, messageId, {
sendStatus: MESSAGE_SEND_STATUS.FAILED,
});
throw err;
}
}
/**
* 重试失败的本地消息
* @param {string} conversationId
* @param {string} localMessageId 原 messageId
*/
async retrySend(conversationId, localMessageId) {
const list = this.state.messagesByConversation[conversationId] || [];
const msg = list.find((m) => m.messageId === localMessageId);
if (!msg || msg.sendStatus !== MESSAGE_SEND_STATUS.FAILED) return;
this._patchMessage(conversationId, localMessageId, {
sendStatus: MESSAGE_SEND_STATUS.SENDING,
});
try {
const result = await messageService.sendMessage(conversationId, {
content: msg.content,
messageId: localMessageId,
clientSentAt: msg.sentAt,
});
this._replaceMessage(conversationId, localMessageId, {
...msg,
messageId: result.messageId || localMessageId,
sentAt: result.sentAt || msg.sentAt,
sendStatus: MESSAGE_SEND_STATUS.SENT,
});
} catch (err) {
this._patchMessage(conversationId, localMessageId, {
sendStatus: MESSAGE_SEND_STATUS.FAILED,
});
throw err;
}
}
/**
* 收到服务端推送的新消息(WebSocket 入口,AGENTS 第 9 条:DB 是 Source of Truth)
* @param {import('../types').Message} msg
*/
onIncomingMessage(msg) {
const cid = msg.conversationId;
const list = this.state.messagesByConversation[cid];
if (list) {
// 去重:messageId 已存在则不重复入队
if (!list.some((m) => m.messageId === msg.messageId)) {
this._appendMessage(cid, { ...msg, sendStatus: MESSAGE_SEND_STATUS.SENT });
}
}
// 更新会话列表里 lastMessage + 未读
this._patchConversationLastMessage(cid, msg.content, msg.sentAt);
if (cid !== this.state.activeConversationId) {
this._bumpUnread(cid);
}
// 通知页面监听器
const listener = this._realtimeListeners.get(cid);
if (listener) listener(msg);
}
/** 标记会话已读(清未读 + 同步服务端) */
async markConversationRead(conversationId) {
const conv = this.state.conversations.find((c) => c.conversationId === conversationId);
if (conv && conv.unreadCount > 0) {
this.setState({
conversations: this.state.conversations.map((c) =>
c.conversationId === conversationId ? { ...c, unreadCount: 0 } : c,
),
});
await this.refreshTotalUnread();
}
}
/** 刷新总未读数(Tab 徽标用,真值以服务端为准) */
async refreshTotalUnread() {
try {
const { unreadCount } = await messageService.getNotificationsUnreadCount();
this.setState({ totalUnreadCount: unreadCount });
this.persist(unreadCount, STORAGE_KEYS.TOTAL_UNREAD_COUNT);
this._unreadListeners.forEach((fn) => fn(unreadCount));
} catch (err) {
// 兜底:本地累加
const sum = this.state.conversations.reduce((s, c) => s + (c.unreadCount || 0), 0);
this.setState({ totalUnreadCount: sum });
}
}
/**
* Block/Unmatch 后由 matchStore 编排调用,标记会话不可发消息
* (AGENTS 第 4 条)
*/
markConversationBlocked(conversationId) {
this.setState({
conversations: this.state.conversations.map((c) =>
c.conversationId === conversationId
? { ...c, canSendMessage: false, relationStatus: 'blocked' }
: c,
),
});
}
/**
* 注册实时消息监听(聊天页 onLoad 时调用)
* @param {string} conversationId
* @param {(msg: import('../types').Message) => void} listener
* @returns {() => void}
*/
onMessage(conversationId, listener) {
this._realtimeListeners.set(conversationId, listener);
return () => this._realtimeListeners.delete(conversationId);
}
setConnectionState(state) {
this.setState({ connectionState: state });
}
// ============ 内部辅助 ============
_appendMessage(conversationId, msg) {
const list = this.state.messagesByConversation[conversationId] || [];
const next = [...list, msg].slice(-PER_CONVERSATION_CACHE * 2);
this.setState({
messagesByConversation: {
...this.state.messagesByConversation,
[conversationId]: next,
},
});
}
_patchMessage(conversationId, messageId, patch) {
const list = this.state.messagesByConversation[conversationId] || [];
const next = list.map((m) =>
m.messageId === messageId ? { ...m, ...patch } : m,
);
this.setState({
messagesByConversation: {
...this.state.messagesByConversation,
[conversationId]: next,
},
});
}
_replaceMessage(conversationId, oldId, newMsg) {
const list = this.state.messagesByConversation[conversationId] || [];
const idx = list.findIndex((m) => m.messageId === oldId);
if (idx === -1) {
this._appendMessage(conversationId, newMsg);
return;
}
const next = [...list];
next[idx] = newMsg;
this.setState({
messagesByConversation: {
...this.state.messagesByConversation,
[conversationId]: next,
},
});
}
_patchConversationLastMessage(conversationId, content, sentAt) {
this.setState({
conversations: this.state.conversations.map((c) =>
c.conversationId === conversationId
? { ...c, lastMessage: content, lastMessageAt: sentAt }
: c,
),
});
}
_bumpUnread(conversationId) {
this.setState({
conversations: this.state.conversations.map((c) =>
c.conversationId === conversationId
? { ...c, unreadCount: (c.unreadCount || 0) + 1 }
: c,
),
});
}
}
const messageStore = new MessageStore();
module.exports = messageStore;
+7
View File
@@ -0,0 +1,7 @@
/**
* stores/stores.js 兼容包装
*
* 微信小程序编译器不自动将 require('./stores') 解析为 stores/index.js,
* 此处提供显式包装以确保开发工具正确加载。
*/
module.exports = require('./index');
+248
View File
@@ -0,0 +1,248 @@
/**
* UserStore(规范 §131)
*
* 职责:
* - 维护 /me/status 返回的 UserState 聚合(决定路由 / 权限 / Tab)
* - 维护本人资料 Profile 与 PartnerPreference 缓存
* - 维护实名认证状态、审核状态
* - 关系状态切换(暂停 / 恢复 / 正在了解)
*
* 不变量(AGENTS.md):
* - 第 8 条:敏感数据默认拒绝——Profile 中 birthDate 完整值仅服务端持有,
* 本地缓存不存身份证号、手机号、完整生日。
* - 第 2 条:Required 不可自动放宽——PartnerPreference 中的 required 字段
* 前端展示为硬约束标识,不允许任何自动软化为 preferred。
*/
const BaseStore = require('./baseStore');
const { userService, profileService, verificationService } = require('../services');
const { STORAGE_KEYS, PROFILE_COMPLETION, PROFILE_REVIEW, ACCOUNT_STATUS } = require('../constants');
class UserStore extends BaseStore {
constructor() {
super({
/** @type {import('../types').UserState|null} */
userState: null,
/** @type {import('../types').Profile|null} */
profile: null,
/** @type {import('../types').PartnerPreference|null} */
preferences: null,
/** @type {import('../types').PrivacySettings|null} */
privacySettings: null,
/** @type {'idle'|'pending'|'approved'|'rejected'|'not_submitted'} */
verificationStatus: 'not_submitted',
/** @type {'idle'|'loading'|'error'} */
loadStatus: 'idle',
lastError: null,
});
}
/** 冷启动从 storage 还原轻量状态,避免白屏 */
hydrate() {
const statusSnap = BaseStore.hydrate(STORAGE_KEYS.USER_STATUS_SNAPSHOT);
if (statusSnap) this.setState({ userState: statusSnap });
const profileSnap = BaseStore.hydrate(STORAGE_KEYS.CURRENT_USER_PROFILE);
if (profileSnap) this.setState({ profile: profileSnap });
}
/** 拉取并缓存 /me/status(决定路由 / Tab / 权限) */
async fetchStatus() {
this.setState({ loadStatus: 'loading', lastError: null });
try {
const userState = await userService.getStatus();
this.setState({ userState, loadStatus: 'idle' });
this.persist(userState, STORAGE_KEYS.USER_STATUS_SNAPSHOT);
return userState;
} catch (err) {
this.setState({ loadStatus: 'error', lastError: err });
throw err;
}
}
/** 拉取本人资料 */
async fetchProfile() {
const profile = await profileService.getProfile();
this.setState({ profile });
this.persist(profile, STORAGE_KEYS.CURRENT_USER_PROFILE);
return profile;
}
/**
* 更新本人资料
* 注意:本方法不校验字数,校验由调用方(页面)按 BUSINESS_LIMITS 执行
*/
async updateProfile(payload) {
const profile = await profileService.updateProfile(payload);
this.setState({ profile });
this.persist(profile, STORAGE_KEYS.CURRENT_USER_PROFILE);
return profile;
}
/** 拉取择偶条件 */
async fetchPreferences() {
const preferences = await profileService.getPreferences();
this.setState({ preferences });
return preferences;
}
/**
* 更新择偶条件
* 调用方需校验 required 字段不可自动放宽(AGENTS 第 2 条),UI 必须明确告知用户
*/
async updatePreferences(payload) {
const preferences = await profileService.updatePreferences(payload);
this.setState({ preferences });
return preferences;
}
/** 拉取隐私设置 */
async fetchPrivacySettings() {
const privacySettings = await profileService.getPrivacySettings();
this.setState({ privacySettings });
return privacySettings;
}
/** 更新隐私设置 */
async updatePrivacySettings(payload) {
const privacySettings = await profileService.updatePrivacySettings(payload);
this.setState({ privacySettings });
return privacySettings;
}
/** 拉取实名认证状态 */
async fetchVerificationStatus() {
const result = await verificationService.getStatus();
this.setState({ verificationStatus: result.status });
return result;
}
/** 提交实名认证 */
async submitVerification(payload) {
const result = await verificationService.submit(payload);
this.setState({ verificationStatus: 'pending' });
return result;
}
/** 拉取审核状态 */
async fetchReviewStatus() {
return profileService.getReviewStatus();
}
/**
* 提交资料审核
*
* AGENTS 第 9 条:PostgreSQL 是 Source of Truth——审核状态以服务端返回为准,
* 本地不预判结果;提交成功后同步更新 userState.profileReviewStatus 为 'pending',
* 以便路由守卫立即把用户引导至"审核中"页面,避免重复提交。
*
* @returns {Promise<{ reviewStatus: 'pending', submittedAt: number }>}
*/
async submitReview() {
const result = await profileService.submitReview();
if (this.state.userState) {
this.setState({
userState: {
...this.state.userState,
profileReviewStatus: PROFILE_REVIEW.PENDING,
profileCompletionStatus: PROFILE_COMPLETION.COMPLETED,
},
});
this.persist(this.state.userState, STORAGE_KEYS.USER_STATUS_SNAPSHOT);
}
return result;
}
/**
* 拉取婚恋关系状态(REST §83 章)
* 同步更新 userState.relationshipStatus / recommendationEnabled / matchingWith
* @returns {Promise<{ status: string, recommendationEnabled: boolean, matchingWith?: object }>}
*/
async fetchRelationshipStatus() {
const result = await profileService.getRelationshipStatus();
if (this.state.userState) {
this.setState({
userState: {
...this.state.userState,
relationshipStatus: result.status,
recommendationEnabled: result.recommendationEnabled,
matchingWith: result.matchingWith || null,
},
});
this.persist(this.state.userState, STORAGE_KEYS.USER_STATUS_SNAPSHOT);
}
return result;
}
/** 暂停寻找 */
async pauseSearching() {
const result = await profileService.pauseSearching();
if (this.state.userState) {
this.setState({
userState: {
...this.state.userState,
relationshipStatus: 'paused',
recommendationEnabled: result.recommendationEnabled ?? false,
},
});
}
return result;
}
/** 恢复寻找 */
async resumeSearching() {
const result = await profileService.resumeSearching();
if (this.state.userState) {
this.setState({
userState: {
...this.state.userState,
relationshipStatus: 'searching',
recommendationEnabled: result.recommendationEnabled ?? true,
},
});
}
return result;
}
/** 设置为正在了解 */
async markGettingToKnow() {
const result = await profileService.markGettingToKnow();
if (this.state.userState) {
this.setState({
userState: {
...this.state.userState,
relationshipStatus: 'getting_to_know',
recommendationEnabled: result.recommendationEnabled ?? false,
},
});
}
return result;
}
// ============ 派生 getters ============
get isLoggedIn() {
return !!this.state.userState;
}
get isRealNameVerified() {
return !!this.state.userState?.isRealNameVerified;
}
get isProfileCompleted() {
return this.state.userState?.profileCompletionStatus === PROFILE_COMPLETION.COMPLETED;
}
get isProfileApproved() {
return this.state.userState?.profileReviewStatus === PROFILE_REVIEW.APPROVED;
}
get isAccountNormal() {
return this.state.userState?.accountStatus === ACCOUNT_STATUS.NORMAL;
}
/** 推荐页是否可见(综合账户态 + 隐私设置) */
get recommendationEnabled() {
return !!this.state.userState?.recommendationEnabled && this.isAccountNormal;
}
}
const userStore = new UserStore();
module.exports = userStore;