feat(message): implement version-based incremental sync and WS compression
Some checks failed
Frontend CI / ota-android (push) Successful in 1m58s
Frontend CI / ota-ios (push) Successful in 2m2s
Frontend CI / build-android-apk (push) Failing after 3m22s
Frontend CI / build-and-push-web (push) Successful in 4m19s

Implement a more efficient conversation synchronization mechanism using
version numbers instead of sequence numbers to reduce bandwidth usage.
This includes adding support for Gzip decompression on WebSocket
messages to optimize data transfer.

- Add `pako` for WebSocket message decompression
- Implement `getSyncByVersion` in `MessageService`
- Implement `syncByVersion` in `MessageSyncService` to handle incremental
  updates and conversation state changes
- Update `WebSocketService` to support binary frames and Gzip inflation
- Add `syncVersion` to `MessageStore` for tracking synchronization state
This commit is contained in:
2026-05-17 23:37:38 +08:00
parent 404b3fabe7
commit fb67fb6d5b
6 changed files with 132 additions and 4 deletions

15
package-lock.json generated
View File

@@ -43,6 +43,7 @@
"jpush-react-native": "^3.2.6", "jpush-react-native": "^3.2.6",
"katex": "^0.16.42", "katex": "^0.16.42",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"pako": "^2.1.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
@@ -64,6 +65,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/cli": "^20.1.2", "@react-native-community/cli": "^20.1.2",
"@types/pako": "^2.0.4",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"typescript": "~5.9.2" "typescript": "~5.9.2"
@@ -3845,6 +3847,13 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
@@ -9082,6 +9091,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",

View File

@@ -49,6 +49,7 @@
"jpush-react-native": "^3.2.6", "jpush-react-native": "^3.2.6",
"katex": "^0.16.42", "katex": "^0.16.42",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"pako": "^2.1.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
@@ -70,6 +71,7 @@
}, },
"devDependencies": { "devDependencies": {
"@react-native-community/cli": "^20.1.2", "@react-native-community/cli": "^20.1.2",
"@types/pako": "^2.0.4",
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"typescript": "~5.9.2" "typescript": "~5.9.2"

View File

@@ -1,3 +1,5 @@
import pako from 'pako';
import { AppState, AppStateStatus } from 'react-native'; import { AppState, AppStateStatus } from 'react-native';
import { api, WS_URL } from './api'; import { api, WS_URL } from './api';
@@ -329,7 +331,7 @@ class WebSocketService {
private fallbackCheckTimer: ReturnType<typeof setTimeout> | null = null; private fallbackCheckTimer: ReturnType<typeof setTimeout> | null = null;
private getWSUrl(token: string | null): string { private getWSUrl(token: string | null): string {
return `${WS_URL}?token=${encodeURIComponent(token || '')}`; return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`;
} }
async connect(): Promise<boolean> { async connect(): Promise<boolean> {
@@ -354,6 +356,7 @@ class WebSocketService {
console.log('[WebSocket] Connecting to:', url.replace(token, '***')); console.log('[WebSocket] Connecting to:', url.replace(token, '***'));
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('[WebSocket] Connected successfully'); console.log('[WebSocket] Connected successfully');
@@ -392,9 +395,17 @@ class WebSocketService {
} }
} }
private handleIncoming(data: string): void { private handleIncoming(data: string | ArrayBuffer): void {
let textData: string;
if (typeof data === 'string') {
textData = data;
} else {
// Binary frame — gzip decompress
const decompressed = pako.inflate(new Uint8Array(data as ArrayBuffer));
textData = new TextDecoder('utf-8').decode(decompressed);
}
// 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析 // 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析
const lines = data.split('\n'); const lines = textData.split('\n');
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
@@ -402,7 +413,7 @@ class WebSocketService {
const msg: WSServerMessage = JSON.parse(trimmed); const msg: WSServerMessage = JSON.parse(trimmed);
this.processMessage(msg); this.processMessage(msg);
} catch (error) { } catch (error) {
console.error('Failed to parse WebSocket message:', error, 'raw:', trimmed); console.error('Failed to parse WebSocket message:', error);
} }
} }
} }

View File

@@ -476,6 +476,37 @@ class MessageService {
return response.data?.conversations || []; return response.data?.conversations || [];
} }
/**
* 增量同步(基于版本号)
* GET /api/v1/conversations/sync?version=N&limit=100
*/
async getSyncByVersion(version: number, limit: number = 100): Promise<{
current_version: number;
changes: Array<{
conversation_id: string;
change_type: string;
max_seq?: number;
last_message_at?: number;
version: number;
}>;
full_sync: boolean;
has_more: boolean;
}> {
const response = await api.get<{
current_version: number;
changes: Array<{
conversation_id: string;
change_type: string;
max_seq?: number;
last_message_at?: number;
version: number;
}>;
full_sync: boolean;
has_more: boolean;
}>('/conversations/sync', { version, limit });
return response.data;
}
/** /**
* 获取系统消息未读数 * 获取系统消息未读数
* GET /api/v1/messages/system/unread-count * GET /api/v1/messages/system/unread-count

View File

@@ -466,6 +466,65 @@ export class MessageSyncService implements IMessageSyncService {
} }
} }
/**
* 基于版本号的增量同步(替代 syncBySeq减少带宽
* 需要本地存储 syncVersion首次同步时 version=0 会触发 full_sync
*/
async syncByVersion(): Promise<boolean> {
try {
const store = useMessageStore.getState();
const version = store.syncVersion ?? 0;
const result = await messageService.getSyncByVersion(version, 100);
if (!result) return false;
// full_sync 表示需要全量刷新
if (result.full_sync) {
return false;
}
if (result.changes.length === 0) {
// 保存当前版本号,下次增量同步时使用
useMessageStore.getState().setSyncVersion(result.current_version);
return true;
}
// 按变更类型处理
const staleIds: string[] = [];
for (const change of result.changes) {
const normId = normalizeConversationId(change.conversation_id);
if (change.change_type === 'hide') {
// 用户隐藏了会话,从本地移除
store.removeConversation(normId);
} else {
// new_msg, read, pin, unpin, mute, unmute, group_update
staleIds.push(normId);
}
}
// 刷新有变更的会话
if (staleIds.length > 0) {
const detailPromises = staleIds.map(id =>
this.fetchConversationDetail(id).catch(() => {})
);
await Promise.all(detailPromises);
}
// 保存版本号
useMessageStore.getState().setSyncVersion(result.current_version);
// has_more 时继续拉取
if (result.has_more) {
return this.syncByVersion();
}
return true;
} catch (error) {
console.error('[MessageSyncService] syncByVersion 失败:', error);
return false;
}
}
/** /**
* 检查是否可加载更多会话 * 检查是否可加载更多会话
*/ */

View File

@@ -53,6 +53,9 @@ export interface MessageState {
// 会话免打扰状态 - 按会话ID存储 // 会话免打扰状态 - 按会话ID存储
notificationMutedMap: Map<string, boolean>; notificationMutedMap: Map<string, boolean>;
// 版本日志同步游标
syncVersion: number | null;
} }
// ==================== Actions 接口 ==================== // ==================== Actions 接口 ====================
@@ -90,6 +93,7 @@ export interface MessageActions {
setTypingUsers: (groupId: string, users: string[]) => void; setTypingUsers: (groupId: string, users: string[]) => void;
setMutedStatus: (groupId: string, isMuted: boolean) => void; setMutedStatus: (groupId: string, isMuted: boolean) => void;
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void; setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void;
setSyncVersion: (version: number) => void;
setInitialized: (initialized: boolean) => void; setInitialized: (initialized: boolean) => void;
// 原子性更新(避免中间渲染状态) // 原子性更新(避免中间渲染状态)
@@ -121,6 +125,7 @@ const initialState: MessageState = {
typingUsersMap: new Map(), typingUsersMap: new Map(),
mutedStatusMap: new Map(), mutedStatusMap: new Map(),
notificationMutedMap: new Map(), notificationMutedMap: new Map(),
syncVersion: null,
}; };
// ==================== 工具函数 ==================== // ==================== 工具函数 ====================
@@ -189,6 +194,7 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
typingUsersMap: state.typingUsersMap, typingUsersMap: state.typingUsersMap,
mutedStatusMap: state.mutedStatusMap, mutedStatusMap: state.mutedStatusMap,
notificationMutedMap: state.notificationMutedMap, notificationMutedMap: state.notificationMutedMap,
syncVersion: state.syncVersion,
}; };
}, },
@@ -387,6 +393,10 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
}); });
}, },
setSyncVersion: (version: number) => {
set({ syncVersion: version });
},
setInitialized: (initialized: boolean) => { setInitialized: (initialized: boolean) => {
set({ isInitialized: initialized }); set({ isInitialized: initialized });
}, },