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

@@ -11,6 +11,7 @@
*/
import { showPrompt } from '../ui/promptService';
import { ApiError, isNetworkError } from './api';
export enum AppErrorCode {
NETWORK_ERROR = 'NETWORK_ERROR',
@@ -45,6 +46,19 @@ const ERROR_MESSAGES: Record<AppErrorCode, string> = {
function classifyError(error: unknown): AppErrorCode {
if (!error) return AppErrorCode.UNKNOWN;
// 优先识别项目自有 ApiError含 errorCode 字段,比 message 关键词匹配更可靠)
// 修复衔接缺口api.ts 用 status=503 + errorCode='NETWORK_ERROR' 表示网络错误,
// 但原逻辑只看 status≥500 会误判为 SERVER_ERROR。这里用 isNetworkError 精确识别。
if (isNetworkError(error)) return AppErrorCode.NETWORK_ERROR;
if (error instanceof ApiError) {
if (error.errorCode === 'AUTH_ERROR' || error.code === 401) return AppErrorCode.AUTH_ERROR;
if (error.errorCode === 'FORBIDDEN' || error.code === 403) return AppErrorCode.FORBIDDEN;
if (error.errorCode === 'NOT_FOUND' || error.code === 404) return AppErrorCode.NOT_FOUND;
if (error.errorCode === 'VERIFICATION_REQUIRED') return AppErrorCode.VALIDATION_ERROR;
if (error.code === 400 || error.code === 422) return AppErrorCode.VALIDATION_ERROR;
if (typeof error.code === 'number' && error.code >= 500) return AppErrorCode.SERVER_ERROR;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();

View File

@@ -304,6 +304,11 @@ class WebSocketService {
preventDisconnectOnBackground = false;
private getWSUrl(token: string | null): string {
// 当前仅携带 token + compress。
// 待后端支持 event 级 resume 后,可在此拼接 last_event_id 实现断点续传:
// const eventIdParam = this.lastEventId ? `&last_event_id=${encodeURIComponent(this.lastEventId)}` : '';
// return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1${eventIdParam}`;
// 届时重连后服务端从 lastEventId 续传断线期间的事件,无需全量 re-sync。
return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`;
}