fix(message): improve message synchronization and reliability
Refactor the messaging subsystem to enhance data consistency, prevent race conditions during WebSocket reconnection, and ensure accurate unread counts.
- **WebSocket Reliability**: Implement `client_msg_id` for precise message ACK matching, preventing incorrect pending message resolution in high-frequency scenarios.
- **Sync Logic**: Update `WSMessageHandler` and `MessageManager` to use a bootstrapping state, ensuring buffered SSE events are flushed only after the initial synchronization is complete.
- **State Consistency**:
- Introduce atomic unread count increments to prevent lost updates.
- Implement conditional rollback for optimistic read receipts, ensuring that failed API calls do not overwrite newer, valid read states.
- **Resource Management**: Add reference counting to `useMessages` hook to prevent premature clearing of loading states during rapid conversation switching or React StrictMode double-invocations.
- **Data Integrity**: Update `UserCacheService` to re-read the latest message list before applying sender info enrichment, ensuring new messages arriving during the async process are correctly processed.
This commit is contained in:
@@ -357,7 +357,7 @@ class WebSocketService {
|
||||
this.markActivity();
|
||||
this.startHeartbeat();
|
||||
this.connectionHandlers.forEach(h => h());
|
||||
|
||||
|
||||
// 发送队列中的消息
|
||||
this.flushPendingMessages();
|
||||
};
|
||||
@@ -776,10 +776,19 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
private handleMessageSent(payload: any): void {
|
||||
// 找到对应的发送请求并resolve
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
p.type === 'chat' && p.payload.conversation_id === payload.conversation_id
|
||||
);
|
||||
// 使用后端回带的 client_msg_id 精确匹配单条消息
|
||||
// 避免同会话连续发送时,第一条 ACK 误清掉第二条的 pending 记录
|
||||
const clientId = payload?.client_msg_id;
|
||||
let pendingMsg: PendingMessage | undefined;
|
||||
if (clientId) {
|
||||
pendingMsg = this.pendingMessages.find(p => p.id === clientId);
|
||||
}
|
||||
// 兜底:旧版后端不回带 client_msg_id 时,按 conversation_id + 类型匹配最旧一条
|
||||
if (!pendingMsg) {
|
||||
pendingMsg = this.pendingMessages.find(p =>
|
||||
p.type === 'chat' && p.payload.conversation_id === payload.conversation_id
|
||||
);
|
||||
}
|
||||
if (pendingMsg) {
|
||||
pendingMsg.resolve(payload);
|
||||
this.removePendingMessage(pendingMsg.id);
|
||||
@@ -787,9 +796,16 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
private handleMessageRecalled(payload: any): void {
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
p.type === 'recall' && p.payload.message_id === payload.message_id
|
||||
);
|
||||
const clientId = payload?.client_msg_id;
|
||||
let pendingMsg: PendingMessage | undefined;
|
||||
if (clientId) {
|
||||
pendingMsg = this.pendingMessages.find(p => p.id === clientId);
|
||||
}
|
||||
if (!pendingMsg) {
|
||||
pendingMsg = this.pendingMessages.find(p =>
|
||||
p.type === 'recall' && p.payload.message_id === payload.message_id
|
||||
);
|
||||
}
|
||||
if (pendingMsg) {
|
||||
pendingMsg.resolve(payload);
|
||||
this.removePendingMessage(pendingMsg.id);
|
||||
@@ -880,16 +896,18 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
const messageId = `msg_${++this.messageIdCounter}_${Date.now()}`;
|
||||
// 携带 client_msg_id:用于服务器回 ACK / message_sent 时精确匹配单条消息
|
||||
const payloadWithId = { ...payload, client_msg_id: messageId };
|
||||
const message = {
|
||||
type,
|
||||
payload,
|
||||
payload: payloadWithId,
|
||||
};
|
||||
|
||||
// 添加到待处理队列
|
||||
const pendingMsg: PendingMessage = {
|
||||
id: messageId,
|
||||
type,
|
||||
payload,
|
||||
payload: payloadWithId,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
@@ -929,6 +947,11 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
// 刷新待发送消息队列
|
||||
// 过期判定:
|
||||
// - 单条 sendViaWebSocket 自带 10s 超时,正常情况下 pending 消息不会超过这么久
|
||||
// - 重连后链路刚恢复时,距离"刚入队"的消息一律视为有效(避免把刚刚挂起的消息误清)
|
||||
// - 兜底:超过 STALE_PENDING_MS(60s)一定视为过期丢弃
|
||||
private static readonly STALE_PENDING_MS = 60000;
|
||||
private flushPendingMessages(): void {
|
||||
if (this.pendingMessages.length === 0) return;
|
||||
|
||||
@@ -938,7 +961,9 @@ class WebSocketService {
|
||||
|
||||
// 分离过期和有效的消息
|
||||
for (const msg of this.pendingMessages) {
|
||||
if (now - msg.timestamp > 30000) {
|
||||
const ageMs = now - msg.timestamp;
|
||||
const isStale = ageMs > WebSocketService.STALE_PENDING_MS;
|
||||
if (isStale) {
|
||||
expiredMessages.push(msg);
|
||||
} else {
|
||||
validMessages.push(msg);
|
||||
@@ -950,13 +975,32 @@ class WebSocketService {
|
||||
msg.reject(new Error('Message expired'));
|
||||
});
|
||||
|
||||
// 重新发送有效消息
|
||||
this.pendingMessages = [];
|
||||
validMessages.forEach(msg => {
|
||||
this.sendViaWebSocket(msg.type, msg.payload)
|
||||
.then(msg.resolve)
|
||||
.catch(msg.reject);
|
||||
});
|
||||
// 重新发送有效消息:直接复用原 client_msg_id,否则重连后服务端 ACK 无法回带到原 pending 记录
|
||||
// 注意:不能调 sendViaWebSocket(会重新生成 id 并入队),必须直接 ws.send
|
||||
this.pendingMessages = validMessages;
|
||||
for (let i = 0; i < validMessages.length; i++) {
|
||||
const msg = validMessages[i];
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
// 链路断开,剩余消息留在队列等下次重连
|
||||
break;
|
||||
}
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ type: msg.type, payload: msg.payload }));
|
||||
} catch {
|
||||
// ws.send 抛异常时 reject 并移出队列,避免无限残留
|
||||
msg.reject(new Error('WebSocket send failed on flush'));
|
||||
this.removePendingMessage(msg.id);
|
||||
continue;
|
||||
}
|
||||
// 重置 10s 超时
|
||||
setTimeout(() => {
|
||||
const index = this.pendingMessages.findIndex(p => p.id === msg.id);
|
||||
if (index >= 0) {
|
||||
this.pendingMessages[index].reject(new Error('Message timeout'));
|
||||
this.removePendingMessage(msg.id);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
private removePendingMessage(id: string): void {
|
||||
|
||||
Reference in New Issue
Block a user