chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user