chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* stores/stores.js 兼容包装
|
||||
*
|
||||
* 微信小程序编译器不自动将 require('./stores') 解析为 stores/index.js,
|
||||
* 此处提供显式包装以确保开发工具正确加载。
|
||||
*/
|
||||
module.exports = require('./index');
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user