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

397 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;