diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 60f896e..dd55157 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -16,6 +16,7 @@ interface ExportWorkerConfig { resourcesPath?: string userDataPath?: string logEnabled?: boolean + isPackaged?: boolean } const config = workerData as ExportWorkerConfig @@ -150,7 +151,10 @@ async function run() { decryptKey: config.decryptKey, myWxid: config.myWxid, imageXorKey: config.imageXorKey, - imageAesKey: config.imageAesKey + imageAesKey: config.imageAesKey, + resourcesPath: config.resourcesPath, + appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, + isPackaged: config.isPackaged }) const onProgress = (progress: any) => queueProgress(progress) @@ -173,7 +177,10 @@ async function run() { chatService.setRuntimeConfig({ dbPath: config.dbPath, decryptKey: config.decryptKey, - myWxid: config.myWxid + myWxid: config.myWxid, + resourcesPath: config.resourcesPath, + appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, + isPackaged: config.isPackaged }) result = await contactExportService.exportContacts( String(config.outputDir || ''), diff --git a/electron/main.ts b/electron/main.ts index cf80daf..e5631b3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2349,8 +2349,8 @@ function registerIpcHandlers() { return chatService.getContactTypeCounts() }) - ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { - return chatService.getSessionMessageCounts(sessionIds) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => { + return chatService.getSessionMessageCounts(sessionIds, options) }) ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { @@ -3213,7 +3213,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath, userDataPath, - logEnabled + logEnabled, + isPackaged: app.isPackaged } }) @@ -3344,7 +3345,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled') + logEnabled: cfg.get('logEnabled'), + isPackaged: app.isPackaged } }) @@ -3411,7 +3413,8 @@ function registerIpcHandlers() { myWxid: String(cfg.getMyWxidCleaned() || '').trim(), resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled') + logEnabled: cfg.get('logEnabled'), + isPackaged: app.isPackaged } }) diff --git a/electron/preload.ts b/electron/preload.ts index bb175c0..ba48c62 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -195,7 +195,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), - getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), + getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options), enrichSessionsContactInfo: ( usernames: string[], options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts index e26a4a4..bdb2488 100644 --- a/electron/services/backupService.ts +++ b/electron/services/backupService.ts @@ -460,6 +460,7 @@ export class BackupService { const dbStorage = join(accountDir, 'db_storage') if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } + const accountDirName = basename(accountDir) const opened = await withTimeout( wcdbService.open(accountDir, decryptKey), 15000, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b827d41..dd2111b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,5 +1,6 @@ import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' +import { createRequire } from 'module' import * as path from 'path' import * as fs from 'fs' import * as https from 'https' @@ -453,7 +454,7 @@ class ChatService { this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void { this.runtimeConfig = config } @@ -8613,13 +8614,17 @@ class ChatService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { try { let wasmPath: string - if (app.isPackaged) { - wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + const isPackaged = this.runtimeConfig?.isPackaged ?? app.isPackaged + const resourcesPath = this.runtimeConfig?.resourcesPath ?? process.resourcesPath + const appPath = this.runtimeConfig?.appPath ?? app.getAppPath() + + if (isPackaged) { + wasmPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') if (!existsSync(wasmPath)) { - wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } } else { - wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(appPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } if (!existsSync(wasmPath)) { @@ -8627,7 +8632,9 @@ class ChatService { return null } - const silkWasm = require('silk-wasm') + // 在 worker 环境中使用 createRequire 来正确加载模块 + const requireFromApp = createRequire(join(appPath, 'package.json')) + const silkWasm = requireFromApp('silk-wasm') if (!silkWasm || !silkWasm.decode) { console.error('[ChatService][Voice] silk-wasm module invalid') return null @@ -9456,12 +9463,13 @@ class ChatService { data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit) - if (privateSessionIds.length > 0 && data.private_segments.length === 0) { + if (data.private_sessions.length > 0) { + const sessionsWithMessages = data.private_sessions.map(s => s.session_id) const privateSegments = await this.rebuildMyFootprintPrivateSegments({ begin, end: normalizedEnd, myWxid, - privateSessionIds + privateSessionIds: sessionsWithMessages }) if (privateSegments.length > 0) { data = { @@ -9561,7 +9569,7 @@ class ChatService { myWxid: string privateSessionIds: string[] }): Promise { - const sessionGapSeconds = 10 * 60 + const sessionGapSeconds = 5 * 60 const segments: MyFootprintPrivateSegment[] = [] type WorkingSegment = { @@ -9579,14 +9587,17 @@ class ChatService { } for (const sessionId of params.privateSessionIds) { - const cursorResult = await wcdbService.openMessageCursorLite( + const cursorResult = await wcdbService.openMessageCursor( sessionId, 360, true, - params.begin, - params.end + 0, + 0 ) - if (!cursorResult.success || !cursorResult.cursor) continue + if (!cursorResult.success || !cursorResult.cursor) { + console.log(`[足迹分段] 打开游标失败: ${sessionId}, 原因: ${cursorResult.error || '未知'}`) + continue + } let segmentCursor = 0 let active: WorkingSegment | null = null @@ -9620,19 +9631,30 @@ class ChatService { } let hasMore = true + let batchCount = 0 + let totalMessages = 0 try { while (hasMore) { const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + batchCount++ if (!batchResult.success || !Array.isArray(batchResult.rows)) break hasMore = Boolean(batchResult.hasMore) + totalMessages += batchResult.rows.length for (const row of batchResult.rows as Array>) { const createTime = this.toSafeInt(row.create_time, 0) const localId = this.toSafeInt(row.local_id, 0) const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) + // 过滤时间范围外的消息 + if (createTime > 0 && (createTime < params.begin || createTime > params.end)) { + continue + } + if (createTime > 0) { - const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds) + const referenceTs = lastMessageTs > 0 ? lastMessageTs : (active ? active.end_ts : 0) + const timeDiff = referenceTs > 0 ? createTime - referenceTs : 0 + const needNew = !active || (referenceTs > 0 && timeDiff > sessionGapSeconds) if (needNew) { commit() segmentCursor += 1 diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 72198df..cd88bb0 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -323,7 +323,7 @@ class ExportService { return error } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void { this.runtimeConfig = config imageDecryptService.setRuntimeConfig({ dbPath: config?.dbPath, @@ -331,6 +331,14 @@ class ExportService { imageXorKey: config?.imageXorKey, imageAesKey: config?.imageAesKey }) + chatService.setRuntimeConfig({ + dbPath: config?.dbPath, + decryptKey: config?.decryptKey, + myWxid: config?.myWxid, + resourcesPath: config?.resourcesPath, + appPath: config?.appPath, + isPackaged: config?.isPackaged + }) } private getConfiguredDbPath(): string { @@ -6651,7 +6659,7 @@ class ExportService { if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' - } else if (mediaItem && msg.localType === 3) { + } else if (mediaItem && msg.localType !== 47) { content = mediaItem.relativePath } else { content = this.parseMessageContent( diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 65d4941..1de4612 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -14,6 +14,7 @@ export interface SnsLivePhoto { thumb: string md5?: string token?: string + thumbToken?: string key?: string encIdx?: string } @@ -23,6 +24,7 @@ export interface SnsMedia { thumb: string md5?: string token?: string + thumbToken?: string key?: string encIdx?: string livePhoto?: SnsLivePhoto @@ -126,12 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { let fixedUrl = url.replace('http://', 'https://') - // 只有非视频(即图片)才需要处理 /150 变 /0 + // 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等)变为 /0 if (!isVideo) { - fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1') + const [pathPart, queryPart] = fixedUrl.split('?') + const fixedPath = pathPart.replace(/\/\d+$/, '/0') + fixedUrl = queryPart ? `${fixedPath}?${queryPart}` : fixedPath } - if (!token || fixedUrl.includes('token=')) return fixedUrl + // 如果没有提供新token,直接返回 + if (!token) return fixedUrl + + // 移除已有的token和idx参数 + const [pathPart, queryPart] = fixedUrl.split('?') + if (queryPart) { + const params = queryPart.split('&').filter(p => !p.startsWith('token=') && !p.startsWith('idx=')) + fixedUrl = params.length > 0 ? `${pathPart}?${params.join('&')}` : pathPart + } // 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数 if (isVideo) { @@ -704,6 +716,7 @@ class SnsService { url: urlMatch ? urlMatch[1].trim() : '', thumb: thumbMatch ? thumbMatch[1].trim() : '', token: urlToken || thumbToken, + thumbToken: thumbToken, key: urlKey || thumbKey, md5: urlMd5, encIdx: urlEncIdx || thumbEncIdx @@ -716,19 +729,24 @@ class SnsService { const lpUrlTag = lx.match(/]*)>/i) const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) const lpThumbTag = lx.match(/]*)>/i) - let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined + let lpUrlToken: string | undefined, lpThumbToken: string | undefined + let lpKey: string | undefined, lpEncIdx: string | undefined if (lpUrlTag?.[1]) { const a = lpUrlTag[1] - lpToken = a.match(/token="([^"]+)"/i)?.[1] + lpUrlToken = a.match(/token="([^"]+)"/i)?.[1] lpKey = a.match(/key="([^"]+)"/i)?.[1] lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] } - if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1] - if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1] + if (lpThumbTag?.[1]) { + const a = lpThumbTag[1] + lpThumbToken = a.match(/token="([^"]+)"/i)?.[1] + if (!lpKey) lpKey = a.match(/key="([^"]+)"/i)?.[1] + } item.livePhoto = { url: lpUrl ? lpUrl[1].trim() : '', thumb: lpThumb ? lpThumb[1].trim() : '', - token: lpToken, + token: lpUrlToken || lpThumbToken, + thumbToken: lpThumbToken, key: lpKey, encIdx: lpEncIdx } @@ -1181,16 +1199,18 @@ class SnsService { const fixedMedia = (post.media || []).map((m: any) => ({ url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.token, false), + thumb: fixSnsUrl(m.thumb, m.thumbToken || m.token, false), md5: m.md5, token: m.token, + thumbToken: m.thumbToken, key: isVideoPost ? (videoKey || m.key) : m.key, encIdx: m.encIdx || m.enc_idx, livePhoto: m.livePhoto ? { ...m.livePhoto, url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.thumbToken || m.livePhoto.token, false), token: m.livePhoto.token, + thumbToken: m.livePhoto.thumbToken, key: videoKey || m.livePhoto.key || m.key, encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx } : undefined diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 02e1d8e..eb4f79a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -4364,7 +4364,7 @@ function ExportPage() { try { if (prioritizedSessionIds.length > 0) { patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') - const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) + const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds, { bypassSessionCache: true, preferHintCache: false }) if (isStale()) return { ...accumulatedCounts } if (priorityResult.success) { applyCounts(priorityResult.counts) @@ -4381,7 +4381,7 @@ function ExportPage() { if (remainingSessionIds.length > 0) { patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') - const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) + const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds, { bypassSessionCache: true, preferHintCache: false }) if (isStale()) return { ...accumulatedCounts } if (remainingResult.success) { applyCounts(remainingResult.counts) @@ -7613,12 +7613,29 @@ function ExportPage() { scheduleSessionMutualFriendsWorker() } + // 记录刷新前的会话时间戳 + const oldTimestamps = new Map( + sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0]) + ) + await Promise.all([ loadContactsList({ scopeKey }), loadSnsStats({ full: true }), loadSnsUserPostCounts({ force: true }) ]) + // 找出有变动的会话(最后消息时间变化) + const changedSessions = sessionsRef.current.filter(session => { + const oldTs = oldTimestamps.get(session.username) || 0 + const newTs = session.lastTimestamp || session.sortTimestamp || 0 + return newTs > oldTs + }) + + // 只对有变动的会话重新加载消息数量 + if (changedSessions.length > 0) { + await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey }) + } + const currentDetailSessionId = showSessionDetailPanel ? String(sessionDetail?.wxid || '').trim() : '' diff --git a/src/pages/MyFootprintPage.tsx b/src/pages/MyFootprintPage.tsx index ff7918d..cb4c194 100644 --- a/src/pages/MyFootprintPage.tsx +++ b/src/pages/MyFootprintPage.tsx @@ -770,12 +770,12 @@ function MyFootprintPage() { <>