feat(messaging): implement Outbox persistence with three-tier sync strategy
Some checks failed
Frontend CI / build-android-apk (push) Waiting to run
Frontend CI / ota (android) (push) Successful in 1m52s
Frontend CI / ota (ios) (push) Successful in 2m36s
Frontend CI / build-and-push-web (push) Failing after 1m15s

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:
lafay
2026-06-28 23:19:52 +08:00
parent d8386b5f76
commit f9b0999112
23 changed files with 1094 additions and 85 deletions

View File

@@ -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 机制)。
* 把 MessageResponseDTO映射为 CachedMessageDB 行并落库INSERT OR REPLACE 按 id 幂等)。
* 用于 pending/failed/normal 三态消息的持久化,保证 App 重启后发送状态可恢复。
* 落库失败不阻断 UIfire-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));
}
/**
* 更新会话的最后消息信息
*/