feat(messaging): implement Outbox persistence with three-tier sync strategy
Add Outbox pattern for reliable message sending with offline recovery: - Persist pending/failed messages to SQLite (status field extended with 'pending'|'failed') - App restart scans pending messages and auto-retries failed sends - Add versioned migration system (PRAGMA user_version) for future schema evolution Implement three-tier sync strategy for precision and efficiency: - syncByVersion: precise incremental sync using persisted cursor (Matrix-style) - syncBySeq: fallback seq-based incremental sync - Full refresh: last resort for recovery Add conversation sync state machine for UI error visibility: - 'idle' | 'hydrating' | 'synced' | 'error' states exposed via useMessages hook - Enables loading/error indicators during hydration pipeline Standardize error handling across message services using createErrorHandler. Add tests: messageOutbox.test.ts, syncByVersion.test.ts, syncState.test.ts
This commit is contained in:
@@ -9,6 +9,9 @@ import { messageService } from '@/services/message';
|
||||
import { messageRepository } from '@/database';
|
||||
import type { IMessageSendService } from '../types';
|
||||
import { useMessageStore } from '../store';
|
||||
import { createErrorHandler } from '@/services/core';
|
||||
|
||||
const errorHandler = createErrorHandler('MessageSendService');
|
||||
|
||||
// 临时ID前缀,用于乐观更新
|
||||
const TEMP_ID_PREFIX = 'temp_';
|
||||
@@ -65,6 +68,8 @@ export class MessageSendService implements IMessageSendService {
|
||||
|
||||
// 2. 立即添加到store,用户可以马上看到
|
||||
store.addOrReplaceMessage(conversationId, tempMessage);
|
||||
// Outbox:临时消息落库(status='pending'),保证 App 重启后能恢复未完成的发送
|
||||
this.persistMessage(tempMessage);
|
||||
|
||||
// 3. 更新会话最后消息(乐观更新)
|
||||
this.updateConversationWithNewMessage(conversationId, tempMessage);
|
||||
@@ -96,24 +101,10 @@ export class MessageSendService implements IMessageSendService {
|
||||
// 更新会话最后消息
|
||||
this.updateConversationWithNewMessage(conversationId, successMessage);
|
||||
|
||||
// 保存到本地数据库
|
||||
const textContent = segments
|
||||
?.filter((s: any) => s.type === 'text')
|
||||
.map((s: any) => s.data?.text || '')
|
||||
.join('') || '';
|
||||
|
||||
messageRepository.saveMessage({
|
||||
id: response.id,
|
||||
conversationId,
|
||||
senderId: currentUserId || '',
|
||||
content: textContent,
|
||||
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
isRead: true,
|
||||
createdAt: successMessage.created_at,
|
||||
seq: response.seq,
|
||||
status: 'normal',
|
||||
segments,
|
||||
}).catch(console.error);
|
||||
// Outbox:成功后持久化正式消息(INSERT OR REPLACE 按 id 覆盖),
|
||||
// 并删除 tempId 的 DB 记录,避免重启后重复恢复
|
||||
this.persistMessage(successMessage);
|
||||
messageRepository.delete(tempId).catch((e: unknown) => errorHandler(e));
|
||||
|
||||
return successMessage;
|
||||
}
|
||||
@@ -123,17 +114,27 @@ export class MessageSendService implements IMessageSendService {
|
||||
...tempMessage,
|
||||
status: 'failed',
|
||||
};
|
||||
store.addOrReplaceMessage(conversationId, failedMessage);
|
||||
// 注意:addOrReplaceMessage 按 id 幂等(已存在则不更新),改用 patchMessages 更新 status
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(failedMessage.id), { status: 'failed' });
|
||||
store.patchMessages(conversationId, patches);
|
||||
// Outbox:失败消息落库
|
||||
this.persistMessage(failedMessage);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[MessageSendService] 发送消息失败:', error);
|
||||
errorHandler(error);
|
||||
|
||||
// 6. 发送失败:更新消息状态为失败
|
||||
const failedMessage: MessageResponse = {
|
||||
...tempMessage,
|
||||
status: 'failed',
|
||||
};
|
||||
store.addOrReplaceMessage(conversationId, failedMessage);
|
||||
// addOrReplaceMessage 按 id 幂等不更新,改用 patchMessages 更新 status
|
||||
const patches = new Map<string, Partial<MessageResponse>>();
|
||||
patches.set(String(failedMessage.id), { status: 'failed' });
|
||||
store.patchMessages(conversationId, patches);
|
||||
// Outbox:失败消息落库
|
||||
this.persistMessage(failedMessage);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -161,6 +162,10 @@ export class MessageSendService implements IMessageSendService {
|
||||
const store = useMessageStore.getState();
|
||||
// 移除旧的失败消息,避免 sendMessage 新插入的 pending 消息与之并存
|
||||
store.removeMessage(conversationId, String(failedMessage.id));
|
||||
// Outbox:同步删除 DB 中旧的 failed 记录,避免重启后 getPendingOrFailed 重复恢复
|
||||
// (sendMessage 会用新 tempId 重新落库 pending,重试成功后正常落库 normal 并删新 tempId)
|
||||
messageRepository.delete(String(failedMessage.id))
|
||||
.catch((e: unknown) => errorHandler(e));
|
||||
|
||||
// 重新发送:sendMessage 会重新插入 pending 临时消息并完成后续流程
|
||||
return this.sendMessage(conversationId, failedMessage.segments, {
|
||||
@@ -168,6 +173,33 @@ export class MessageSendService implements IMessageSendService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化消息到本地数据库(Outbox 机制)。
|
||||
* 把 MessageResponse(DTO)映射为 CachedMessage(DB 行)并落库(INSERT OR REPLACE 按 id 幂等)。
|
||||
* 用于 pending/failed/normal 三态消息的持久化,保证 App 重启后发送状态可恢复。
|
||||
* 落库失败不阻断 UI(fire-and-forget + 日志),最坏情况是重启后丢失该条未完成发送。
|
||||
*/
|
||||
private persistMessage(message: MessageResponse): void {
|
||||
const segments = message.segments || [];
|
||||
const textContent = segments
|
||||
.filter((s: any) => s.type === 'text')
|
||||
.map((s: any) => s.data?.text || '')
|
||||
.join('') || '';
|
||||
|
||||
messageRepository.saveMessage({
|
||||
id: String(message.id),
|
||||
conversationId: message.conversation_id,
|
||||
senderId: message.sender_id,
|
||||
content: textContent,
|
||||
type: segments.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||
isRead: true,
|
||||
createdAt: message.created_at,
|
||||
seq: message.seq,
|
||||
status: message.status,
|
||||
segments,
|
||||
}).catch((e: unknown) => errorHandler(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话的最后消息信息
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user