diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 509c3dd..c8ab220 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -1,14 +1,18 @@ import { parentPort, workerData } from 'worker_threads' -import type { ExportOptions } from './services/exportService' interface ExportWorkerConfig { - sessionIds: string[] - outputDir: string - options: ExportOptions + mode?: 'sessions' | 'single' | 'contacts' + sessionIds?: string[] + sessionId?: string + outputDir?: string + outputPath?: string + options?: any taskId?: string dbPath?: string decryptKey?: string myWxid?: string + imageXorKey?: unknown + imageAesKey?: string resourcesPath?: string userDataPath?: string logEnabled?: boolean @@ -20,6 +24,93 @@ const controlState = { stopRequested: false } +const CREATED_PATH_FLUSH_INTERVAL_MS = 200 +const CREATED_PATH_BATCH_LIMIT = 256 +const PROGRESS_POST_INTERVAL_MS = 180 +let queuedCreatedFiles: string[] = [] +let queuedCreatedDirs: string[] = [] +let createdPathFlushTimer: ReturnType | null = null +let pendingProgress: any = null +let progressPostTimer: ReturnType | null = null +let lastProgressPostedAt = 0 + +function flushCreatedPaths() { + if (createdPathFlushTimer) { + clearTimeout(createdPathFlushTimer) + createdPathFlushTimer = null + } + const filePaths = queuedCreatedFiles + const dirPaths = queuedCreatedDirs + queuedCreatedFiles = [] + queuedCreatedDirs = [] + if (!parentPort) return + if (filePaths.length > 0) { + parentPort.postMessage({ type: 'export:createdFiles', filePaths }) + } + if (dirPaths.length > 0) { + parentPort.postMessage({ type: 'export:createdDirs', dirPaths }) + } +} + +function scheduleCreatedPathFlush() { + if (createdPathFlushTimer) return + createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS) +} + +function queueCreatedFile(filePath: string) { + const normalized = String(filePath || '').trim() + if (!normalized) return + queuedCreatedFiles.push(normalized) + if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) { + flushCreatedPaths() + } else { + scheduleCreatedPathFlush() + } +} + +function queueCreatedDir(dirPath: string) { + const normalized = String(dirPath || '').trim() + if (!normalized) return + queuedCreatedDirs.push(normalized) + if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) { + flushCreatedPaths() + } else { + scheduleCreatedPathFlush() + } +} + +function flushProgress() { + if (!pendingProgress) return + if (progressPostTimer) { + clearTimeout(progressPostTimer) + progressPostTimer = null + } + parentPort?.postMessage({ + type: 'export:progress', + data: pendingProgress + }) + pendingProgress = null + lastProgressPostedAt = Date.now() +} + +function queueProgress(progress: any) { + pendingProgress = progress + if (progress?.phase === 'complete') { + flushProgress() + return + } + + const now = Date.now() + const elapsed = now - lastProgressPostedAt + if (elapsed >= PROGRESS_POST_INTERVAL_MS) { + flushProgress() + return + } + + if (progressPostTimer) return + progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed) +} + parentPort?.on('message', (message: any) => { if (!message || typeof message.type !== 'string') return if (message.type === 'export:pause') { @@ -57,32 +148,49 @@ async function run() { exportService.setRuntimeConfig({ dbPath: config.dbPath, decryptKey: config.decryptKey, - myWxid: config.myWxid + myWxid: config.myWxid, + imageXorKey: config.imageXorKey, + imageAesKey: config.imageAesKey }) - const result = await exportService.exportSessions( - Array.isArray(config.sessionIds) ? config.sessionIds : [], - String(config.outputDir || ''), - config.options || { format: 'json' }, - (progress) => { - parentPort?.postMessage({ - type: 'export:progress', - data: progress - }) - }, - config.taskId - ? { - shouldPause: () => controlState.pauseRequested, - shouldStop: () => controlState.stopRequested, - recordCreatedFile: (filePath: string) => { - parentPort?.postMessage({ type: 'export:createdFile', filePath }) - }, - recordCreatedDir: (dirPath: string) => { - parentPort?.postMessage({ type: 'export:createdDir', dirPath }) - } - } - : undefined - ) + const onProgress = (progress: any) => queueProgress(progress) + + const taskControl = config.taskId + ? { + shouldPause: () => controlState.pauseRequested, + shouldStop: () => controlState.stopRequested, + recordCreatedFile: queueCreatedFile, + recordCreatedDir: queueCreatedDir + } + : undefined + + let result: any + if (config.mode === 'contacts') { + const { contactExportService } = await import('./services/contactExportService') + result = await contactExportService.exportContacts( + String(config.outputDir || ''), + config.options || {} + ) + } else if (config.mode === 'single') { + result = await exportService.exportSessionToChatLab( + String(config.sessionId || '').trim(), + String(config.outputPath || '').trim(), + config.options || { format: 'chatlab' }, + onProgress, + taskControl + ) + } else { + result = await exportService.exportSessions( + Array.isArray(config.sessionIds) ? config.sessionIds : [], + String(config.outputDir || ''), + config.options || { format: 'json' }, + onProgress, + taskControl + ) + } + + flushProgress() + flushCreatedPaths() parentPort?.postMessage({ type: 'export:result', @@ -91,6 +199,8 @@ async function run() { } run().catch((error) => { + flushProgress() + flushCreatedPaths() parentPort?.postMessage({ type: 'export:error', error: String(error) diff --git a/electron/main.ts b/electron/main.ts index 85f0863..b57b76b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,7 +23,6 @@ import { KeyServiceMac } from './services/keyServiceMac' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' -import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { cloudControlService } from './services/cloudControlService' @@ -3046,7 +3045,7 @@ function registerIpcHandlers() { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => { const taskId = normalizeExportTaskId(controlOptions?.taskId) - const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined + if (taskId) exportTaskControlService.createControl(taskId, outputDir) if (taskId) activeExportTasks.add(taskId) const PROGRESS_FORWARD_INTERVAL_MS = 180 let pendingProgress: ExportProgress | null = null @@ -3091,17 +3090,13 @@ function registerIpcHandlers() { queueProgress(progress) } - const runMainFallback = async (reason: string) => { - console.warn(`[fallback-export-main] ${reason}`) - return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl) - } - const cfg = configService || new ConfigService() configService = cfg const logEnabled = cfg.get('logEnabled') const dbPath = String(cfg.get('dbPath') || '').trim() const decryptKey = String(cfg.get('decryptKey') || '').trim() const myWxid = String(cfg.get('myWxid') || '').trim() + const imageKeys = cfg.getImageKeysForCurrentWxid() const resourcesPath = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') @@ -3119,6 +3114,8 @@ function registerIpcHandlers() { dbPath, decryptKey, myWxid, + imageXorKey: imageKeys.xorKey, + imageAesKey: imageKeys.aesKey, resourcesPath, userDataPath, logEnabled @@ -3155,6 +3152,20 @@ function registerIpcHandlers() { onProgress(msg.data as ExportProgress) return } + if (msg && msg.type === 'export:createdFiles' && taskId) { + const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : [] + for (const filePath of filePaths) { + exportTaskControlService.recordCreatedFile(taskId, String(filePath || '')) + } + return + } + if (msg && msg.type === 'export:createdDirs' && taskId) { + const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : [] + for (const dirPath of dirPaths) { + exportTaskControlService.recordCreatedDir(taskId, String(dirPath || '')) + } + return + } if (msg && msg.type === 'export:createdFile' && taskId) { exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || '')) return @@ -3191,7 +3202,21 @@ function registerIpcHandlers() { const result = await runWorker() return await finalizeExportTaskControlResult(taskId, result) } catch (error) { - const result = await runMainFallback(error instanceof Error ? error.message : String(error)) + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[export-worker] ${errorMessage}`) + const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : [] + const failedSessionErrors: Record = {} + for (const sessionId of normalizedSessionIds) { + failedSessionErrors[sessionId] = errorMessage + } + const result = { + success: false, + successCount: 0, + failCount: normalizedSessionIds.length, + failedSessionIds: normalizedSessionIds, + failedSessionErrors, + error: `导出 Worker 执行失败: ${errorMessage}` + } return await finalizeExportTaskControlResult(taskId, result) } finally { if (taskId) activeExportTasks.delete(taskId) @@ -3203,12 +3228,136 @@ function registerIpcHandlers() { } }) - ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { - return exportService.exportSessionToChatLab(sessionId, outputPath, options) + ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => { + const cfg = configService || new ConfigService() + configService = cfg + const imageKeys = cfg.getImageKeysForCurrentWxid() + const workerPath = join(__dirname, 'exportWorker.js') + + try { + return await new Promise((resolve) => { + const worker = new Worker(workerPath, { + workerData: { + mode: 'single', + sessionId, + outputPath, + options, + dbPath: String(cfg.get('dbPath') || '').trim(), + decryptKey: String(cfg.get('decryptKey') || '').trim(), + myWxid: String(cfg.get('myWxid') || '').trim(), + imageXorKey: imageKeys.xorKey, + imageAesKey: imageKeys.aesKey, + resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), + userDataPath: app.getPath('userData'), + logEnabled: cfg.get('logEnabled') + } + }) + + let settled = false + const finalize = (value: any) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + resolve(value) + } + const fail = (error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[export-worker-single] ${errorMessage}`) + finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` }) + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'export:progress') { + if (!event.sender.isDestroyed()) { + event.sender.send('export:progress', msg.data) + } + return + } + if (msg && msg.type === 'export:result') { + finalize(msg.data) + return + } + if (msg && msg.type === 'export:error') { + fail(String(msg.error || '导出 Worker 执行失败')) + } + }) + worker.on('error', fail) + worker.on('exit', (code) => { + if (settled) return + if (code === 0) { + finalize({ success: false, error: '导出 Worker 未返回结果' }) + } else { + fail(`导出 Worker 异常退出: ${code}`) + } + }) + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[export-worker-single] ${errorMessage}`) + return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` } + } }) ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => { - return contactExportService.exportContacts(outputDir, options) + const cfg = configService || new ConfigService() + configService = cfg + const workerPath = join(__dirname, 'exportWorker.js') + + try { + return await new Promise((resolve) => { + const worker = new Worker(workerPath, { + workerData: { + mode: 'contacts', + outputDir, + options, + dbPath: String(cfg.get('dbPath') || '').trim(), + decryptKey: String(cfg.get('decryptKey') || '').trim(), + myWxid: String(cfg.get('myWxid') || '').trim(), + resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), + userDataPath: app.getPath('userData'), + logEnabled: cfg.get('logEnabled') + } + }) + + let settled = false + const finalize = (value: any) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + resolve(value) + } + const fail = (error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[export-worker-contacts] ${errorMessage}`) + finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` }) + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'export:result') { + finalize(msg.data) + return + } + if (msg && msg.type === 'export:error') { + fail(String(msg.error || '导出 Worker 执行失败')) + } + }) + worker.on('error', fail) + worker.on('exit', (code) => { + if (settled) return + if (code === 0) { + finalize({ success: false, error: '导出 Worker 未返回结果' }) + } else { + fail(`导出 Worker 异常退出: ${code}`) + } + }) + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[export-worker-contacts] ${errorMessage}`) + return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` } + } }) // 数据分析相关 diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index a3c730b..732a984 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -112,6 +112,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + exportWriteLayout?: 'A' | 'B' | 'C' sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number @@ -271,7 +272,7 @@ async function parallelLimit( class ExportService { private configService: ConfigService - private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null + private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null private contactCache: LRUCache private inlineEmojiCache: LRUCache private htmlStyleCache: string | null = null @@ -287,6 +288,8 @@ class ExportService { private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaRunSourceDedupMap = new Map() private mediaRunMissingImageKeys = new Set() + private activeChatImagePipelineCount = 0 + private chatImagePipelineWaiters: Array<() => void> = [] private mediaFileCacheCleanupPending: Promise | null = null private mediaFileCacheLastCleanupAt = 0 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 @@ -320,8 +323,22 @@ class ExportService { return error } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void { this.runtimeConfig = config + imageDecryptService.setRuntimeConfig({ + dbPath: config?.dbPath, + myWxid: config?.myWxid, + imageXorKey: config?.imageXorKey, + imageAesKey: config?.imageAesKey + }) + } + + private getConfiguredDbPath(): string { + return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() + } + + private getConfiguredMyWxid(): string { + return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() } private normalizeSessionIds(sessionIds: string[]): string[] { @@ -354,6 +371,33 @@ class ExportService { return { start, end } } + private normalizeMaxFileSizeMb(value: unknown): number | undefined { + const raw = Number(value) + if (!Number.isFinite(raw) || raw <= 0) return undefined + return Math.floor(raw) + } + + private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions { + const normalizedDateRange = this.normalizeExportDateRange(options.dateRange) + const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb) + const normalizedWriteLayout = this.resolveExportWriteLayout(options) + return { + ...options, + dateRange: normalizedDateRange, + maxFileSizeMb: normalizedMaxFileSizeMb, + exportWriteLayout: normalizedWriteLayout + } + } + + private resolveExportWriteLayout(options?: Pick | null): 'A' | 'B' | 'C' { + const optionLayout = options?.exportWriteLayout + if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout + const rawWriteLayout = this.configService.get('exportWriteLayout') + return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' + ? rawWriteLayout + : 'B' + } + private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string { const normalized = this.normalizeExportDateRange(dateRange) if (!normalized) return 'all' @@ -370,8 +414,8 @@ class ExportService { const normalizedIds = this.normalizeSessionIds(sessionIds).sort() const senderToken = String(options.senderUsername || '').trim() const dateToken = this.getExportStatsDateRangeToken(options.dateRange) - const dbPath = String(this.configService.get('dbPath') || '').trim() - const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim() + const dbPath = this.getConfiguredDbPath() + const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim() return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}` } @@ -712,6 +756,20 @@ class ExportService { this.mediaRunMissingImageKeys.clear() } + private async runWithChatImagePipelineLimit(fn: () => Promise): Promise { + while (this.activeChatImagePipelineCount >= 2) { + await new Promise((resolve) => this.chatImagePipelineWaiters.push(resolve)) + } + this.activeChatImagePipelineCount += 1 + try { + return await fn() + } finally { + this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1) + const next = this.chatImagePipelineWaiters.shift() + if (next) next() + } + } + private getMediaTelemetrySnapshot(): Partial { const stats = this.mediaExportTelemetry if (!stats) return {} @@ -1577,8 +1635,8 @@ class ExportService { } private resolveStrictEmoticonDbPath(): string | null { - const dbPath = String(this.configService.get('dbPath') || '').trim() - const rawWxid = String(this.configService.get('myWxid') || '').trim() + const dbPath = this.getConfiguredDbPath() + const rawWxid = this.getConfiguredMyWxid() const cleanedWxid = this.cleanAccountDirName(rawWxid) const token = `${dbPath}::${rawWxid}::${cleanedWxid}` if (token === this.emoticonDbPathCacheToken) { @@ -1823,8 +1881,8 @@ class ExportService { } private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { - const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() - const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() + const wxid = this.getConfiguredMyWxid() + const dbPath = this.getConfiguredDbPath() const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim() if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' } if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' } @@ -4092,44 +4150,79 @@ class ExportService { const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise => { if (!imageMd5 && !imageDatName) return null + return this.runWithChatImagePipelineLimit(async () => { + const pickResolvedImagePath = (result: any): string | null => { + if (!result?.success) return null + const resolved = String(result.localPath || '').trim() + return resolved || null + } - const decryptResult = await imageDecryptService.decryptImage({ - sessionId, - imageMd5, - imageDatName, - createTime: msg.createTime, - force: true, // 导出优先高清,失败再回退缩略图 - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: true, - allowCacheIndex: !imageMd5, - suppressEvents: true + const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise => { + const cachedResult = await imageDecryptService.resolveCachedImage({ + sessionId, + imageMd5: candidateMd5, + imageDatName: candidateDatName, + createTime: msg.createTime, + preferFilePath: true, + hardlinkOnly: true, + disableUpdateCheck: true, + allowCacheIndex: true, + suppressEvents: true + }) + return pickResolvedImagePath(cachedResult) + } + + const cachedPath = await resolveCachedPath(imageMd5, imageDatName) + if (cachedPath) { + return cachedPath + } + + const decryptResult = await imageDecryptService.decryptImage({ + sessionId, + imageMd5, + imageDatName, + createTime: msg.createTime, + force: false, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true + }) + const decryptedPath = pickResolvedImagePath(decryptResult) + if (decryptedPath) return decryptedPath + + const localId = Number(msg?.localId || 0) + if (Number.isFinite(localId) && localId > 0) { + const fallback = await chatService.getImageData(sessionId, String(localId)) + if (fallback.success && fallback.data) { + const buffer = Buffer.from(fallback.data, 'base64') + const mime = this.detectMimeType(buffer) || 'image/jpeg' + return `data:${mime};base64,${fallback.data}` + } + } + + if (decryptResult.failureKind === 'decrypt_failed') { + console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`) + } else { + console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`) + } + + const thumbResult = await imageDecryptService.resolveCachedImage({ + sessionId, + imageMd5, + imageDatName, + createTime: msg.createTime, + preferFilePath: true, + hardlinkOnly: true, + disableUpdateCheck: true, + allowCacheIndex: true, + suppressEvents: true + }) + if (thumbResult.success && thumbResult.localPath) { + console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) + return thumbResult.localPath + } + return null }) - if (decryptResult.success && decryptResult.localPath) { - return decryptResult.localPath - } - - if (decryptResult.failureKind === 'decrypt_failed') { - console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`) - } else { - console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`) - } - - const thumbResult = await imageDecryptService.resolveCachedImage({ - sessionId, - imageMd5, - imageDatName, - createTime: msg.createTime, - preferFilePath: true, - disableUpdateCheck: true, - allowCacheIndex: !imageMd5, - suppressEvents: true - }) - if (thumbResult.success && thumbResult.localPath) { - console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) - return thumbResult.localPath - } - return null } // 使用消息对象中已提取的字段,先尝试快速导出。 @@ -4235,11 +4328,10 @@ class ExportService { const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase() if (imageMd5) { imageMd5Set.add(imageMd5) - } else { - const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() - if (md5Pattern.test(imageDatName)) { - imageMd5Set.add(imageDatName) - } + } + const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() + if (md5Pattern.test(imageDatName)) { + imageMd5Set.add(imageDatName) } } @@ -4487,16 +4579,89 @@ class ExportService { */ private extractImageDatName(content: string): string | undefined { if (!content) return undefined - // 尝试从 cdnthumburl 或其他字段提取 - const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content) - if (urlMatch) { - const urlParts = urlMatch[1].split('/') - const last = urlParts[urlParts.length - 1] - if (last && last.includes('_')) { - return last.split('_')[0] - } + const candidate = + this.extractXmlValue(content, 'imgname') || + this.extractXmlValue(content, 'cdnmidimgurl') || + this.extractXmlValue(content, 'cdnthumburl') || + this.extractXmlAttribute(content, 'img', 'imgname') || + this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') || + this.extractXmlAttribute(content, 'img', 'cdnthumburl') + return this.normalizeImageDatNameToken(candidate) + } + + private normalizeImageDatNameToken(value: unknown): string | undefined { + let text = String(value ?? '').trim() + if (!text) return undefined + text = text.replace(/&/g, '&') + try { + if (text.includes('%')) text = decodeURIComponent(text) + } catch { } + + const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text) + if (datLike?.[1]) return datLike[1].toLowerCase() + + const base = text + .split(/[?#]/, 1)[0] + .replace(/^.*[\\/]/, '') + .replace(/\.(?:t\.)?dat$/i, '') + .trim() + if (!base) return undefined + + const cdnToken = base.includes('_') ? base.split('_')[0] : base + const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken) + if (exact?.[1]) return exact[1].toLowerCase() + + const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken) + if (preferred32?.[1]) return preferred32[1].toLowerCase() + const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken) + return fallback?.[1]?.toLowerCase() + } + + private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined { + const buffer = this.decodePackedInfoBuffer(raw) + if (!buffer || buffer.length === 0) return undefined + const printable: number[] = [] + for (const byte of buffer) { + printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20) } - return undefined + const text = Buffer.from(printable).toString('utf-8') + const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text) + if (datLike?.[1]) return datLike[1].toLowerCase() + const fallback = /([0-9a-fA-F]{16,})/.exec(text) + return fallback?.[1]?.toLowerCase() + } + + private extractImageDatNameFromRow(row: Record, content?: string): string | undefined { + const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [ + 'image_path', + 'imagePath', + 'image_dat_name', + 'imageDatName', + 'img_path', + 'imgPath', + 'img_name', + 'imgName' + ])) + if (byColumn) return byColumn + + const packedRaw = this.getRowField(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) + const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw) + if (byPacked) return byPacked + + return this.extractImageDatName(content || '') } /** @@ -4699,8 +4864,8 @@ class ExportService { } private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] { - const dbPath = String(this.configService.get('dbPath') || '').trim() - const rawWxid = String(this.configService.get('myWxid') || '').trim() + const dbPath = this.getConfiguredDbPath() + const rawWxid = this.getConfiguredMyWxid() const cleanedWxid = this.cleanAccountDirName(rawWxid) if (!dbPath) return [] @@ -5050,10 +5215,7 @@ class ExportService { const exportMediaEnabled = options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) const outputDir = path.dirname(outputPath) - const rawWriteLayout = this.configService.get('exportWriteLayout') - const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' - ? rawWriteLayout - : 'A' + const writeLayout = this.resolveExportWriteLayout(options) // A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories. if (writeLayout === 'A' && path.basename(outputDir) === 'texts') { return { @@ -5229,7 +5391,7 @@ class ExportService { : await wcdbService.openMessageCursor( sessionId, batchSize, - true, + false, beginTime, endTime ) @@ -5417,7 +5579,7 @@ class ExportService { if (collectMode === 'full' || collectMode === 'media-fast') { // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined - imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined + imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined videoMd5 = this.extractVideoFileNameFromRow(row, content) xmlType = rowFileHints.xmlType fileName = rowFileHints.fileName @@ -5439,7 +5601,7 @@ class ExportService { if (localType === 3 && content) { // 图片消息 imageMd5 = imageMd5 || this.extractImageMd5(content) - imageDatName = imageDatName || this.extractImageDatName(content) + imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content) } else if (localType === 43 && content) { // 视频消息 videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content) @@ -5587,9 +5749,51 @@ class ExportService { } } + if (rows.length > 1) { + rows.sort((a, b) => { + const timeDelta = (a.createTime || 0) - (b.createTime || 0) + if (timeDelta !== 0) return timeDelta + return (a.localId || 0) - (b.localId || 0) + }) + } + return { rows, memberSet, firstTime, lastTime } } + private async getRecentWcdbCursorLogSummary(sessionId: string): Promise { + try { + const logResult = await wcdbService.getLogs() + if (!logResult.success || !Array.isArray(logResult.logs)) return undefined + const sid = String(sessionId || '').trim() + const interesting = logResult.logs + .filter((line) => { + const text = String(line || '') + if (sid && text.includes(sid)) return true + return text.includes('QueryMessageBatch') || + text.includes('InitExportCursorHeap') || + text.includes('cursor_init') || + text.includes('fetch_message_batch') || + text.includes('open_message_cursor') + }) + .slice(-8) + if (interesting.length === 0) return undefined + return interesting.join(' | ') + } catch { + return undefined + } + } + + private async buildNoMessagesError( + sessionId: string, + collected: { error?: string }, + fallback = '该会话在指定时间范围内没有消息' + ): Promise { + if (collected.error) return collected.error + const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId) + if (!nativeLogSummary) return fallback + return `${fallback};WCDB日志:${nativeLogSummary}` + } + private async backfillMediaFieldsFromMessageDetail( sessionId: string, rows: any[], @@ -5608,7 +5812,7 @@ class ExportService { return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt } if (!targetMediaTypes.has(msg.localType)) return false - if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName + if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName if (msg.localType === 47) return !msg.emojiMd5 if (msg.localType === 43) return !msg.videoMd5 return false @@ -5639,7 +5843,7 @@ class ExportService { if (msg.localType === 3) { const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase() - const imageDatName = (String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) || '').toLowerCase() + const imageDatName = this.extractImageDatNameFromRow(row, content) || '' if (imageMd5) msg.imageMd5 = imageMd5 if (imageDatName) msg.imageDatName = imageDatName return @@ -6111,7 +6315,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') - const rawMyWxid = String(this.configService.get('myWxid') || '').trim() + const rawMyWxid = this.getConfiguredMyWxid() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -6149,7 +6353,7 @@ class ExportService { // 如果没有消息,不创建文件 if (totalMessages === 0) { - return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } + return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } } await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control) @@ -6649,7 +6853,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') - const rawMyWxid = String(this.configService.get('myWxid') || '').trim() + const rawMyWxid = this.getConfiguredMyWxid() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -6687,7 +6891,7 @@ class ExportService { // 如果没有消息,不创建文件 if (totalMessages === 0) { - return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } + return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } } await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) @@ -7380,7 +7584,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') - const rawMyWxid = String(this.configService.get('myWxid') || '').trim() + const rawMyWxid = this.getConfiguredMyWxid() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -7423,7 +7627,7 @@ class ExportService { // 如果没有消息,不创建文件 if (totalMessages === 0) { - return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } + return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } } await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) @@ -8264,7 +8468,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') - const rawMyWxid = String(this.configService.get('myWxid') || '').trim() + const rawMyWxid = this.getConfiguredMyWxid() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -8301,7 +8505,7 @@ class ExportService { // 如果没有消息,不创建文件 if (totalMessages === 0) { - return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } + return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } } await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) @@ -8662,7 +8866,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') - const rawMyWxid = String(this.configService.get('myWxid') || '').trim() + const rawMyWxid = this.getConfiguredMyWxid() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -8697,7 +8901,7 @@ class ExportService { ) let totalMessages = collected.rows.length if (totalMessages === 0) { - return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } + return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } } await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) @@ -9113,7 +9317,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') - const rawMyWxid = String(this.configService.get('myWxid') || '').trim() + const rawMyWxid = this.getConfiguredMyWxid() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) const contactCache = new Map() @@ -9152,7 +9356,7 @@ class ExportService { // 如果没有消息,不创建文件 if (collected.rows.length === 0) { - return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } + return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } } const totalMessages = collected.rows.length @@ -9948,6 +10152,7 @@ class ExportService { pendingSessionIds?: string[] successSessionIds?: string[] failedSessionIds?: string[] + failedSessionErrors?: Record sessionOutputPaths?: Record error?: string }> { @@ -9955,6 +10160,7 @@ class ExportService { let failCount = 0 const successSessionIds: string[] = [] const failedSessionIds: string[] = [] + const failedSessionErrors: Record = {} const sessionOutputPaths: Record = {} const progressEmitter = this.createProgressEmitter(onProgress) let attachMediaTelemetry = false @@ -9972,9 +10178,10 @@ class ExportService { } this.resetMediaRuntimeState() - const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) - ? { ...options, exportVoiceAsText: false } - : options + const normalizedOptions = this.normalizeExportOptionsForRun(options) + const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions) + ? { ...normalizedOptions, exportVoiceAsText: false } + : normalizedOptions const exportMediaEnabled = effectiveOptions.exportMedia === true && Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles) @@ -9982,10 +10189,7 @@ class ExportService { if (exportMediaEnabled) { this.triggerMediaFileCacheCleanup() } - const rawWriteLayout = this.configService.get('exportWriteLayout') - const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' - ? rawWriteLayout - : 'A' + const writeLayout = this.resolveExportWriteLayout(effectiveOptions) const exportBaseDir = writeLayout === 'A' ? path.join(outputDir, 'texts') : outputDir @@ -10020,7 +10224,6 @@ class ExportService { const queue = [...sessionIds] let pauseRequested = false let stopRequested = false - const emptySessionIds = new Set() const sessionMessageCountHints = new Map() const sessionLatestTimestampHints = new Map() const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid) @@ -10033,17 +10236,12 @@ class ExportService { if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) { sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp))) } - if (snapshot.totalCount <= 0) { - emptySessionIds.add(sessionId) - } } } const canUseSessionSnapshotHints = isTextContentBatchExport && this.isUnboundedDateRange(effectiveOptions.dateRange) && !String(effectiveOptions.senderUsername || '').trim() - const canFastSkipEmptySessions = !isTextContentBatchExport && - this.isUnboundedDateRange(effectiveOptions.dateRange) && - !String(effectiveOptions.senderUsername || '').trim() + const canFastSkipEmptySessions = false const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints const precheckSessionIds = canFastSkipEmptySessions ? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId)) @@ -10082,9 +10280,6 @@ class ExportService { if (typeof count === 'number' && Number.isFinite(count) && count >= 0) { sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count))) } - if (typeof count === 'number' && Number.isFinite(count) && count <= 0) { - emptySessionIds.add(batchSessionId) - } } } @@ -10154,6 +10349,7 @@ class ExportService { pendingSessionIds: [...queue], successSessionIds, failedSessionIds, + failedSessionErrors, sessionOutputPaths } } @@ -10166,6 +10362,7 @@ class ExportService { pendingSessionIds: [...queue], successSessionIds, failedSessionIds, + failedSessionErrors, sessionOutputPaths } } @@ -10177,46 +10374,6 @@ class ExportService { const messageCountHint = sessionMessageCountHints.get(sessionId) const latestTimestampHint = sessionLatestTimestampHints.get(sessionId) - if ( - isTextContentBatchExport && - typeof messageCountHint === 'number' && - messageCountHint <= 0 - ) { - successCount++ - successSessionIds.push(sessionId) - activeSessionRatios.delete(sessionId) - completedCount++ - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: sessionInfo.displayName, - currentSessionId: sessionId, - phase: 'complete', - phaseLabel: '该会话没有消息,已跳过', - estimatedTotalMessages: 0, - exportedMessages: 0 - }, { force: true }) - return 'done' - } - - if (emptySessionIds.has(sessionId)) { - successCount++ - successSessionIds.push(sessionId) - activeSessionRatios.delete(sessionId) - completedCount++ - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: sessionInfo.displayName, - currentSessionId: sessionId, - phase: 'complete', - phaseLabel: '该会话没有消息,已跳过', - estimatedTotalMessages: 0, - exportedMessages: 0 - }, { force: true }) - return 'done' - } - const sessionProgress = (progress: ExportProgress) => { const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100 const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0 @@ -10339,6 +10496,7 @@ class ExportService { } else { failCount++ failedSessionIds.push(sessionId) + failedSessionErrors[sessionId] = result.error || '导出失败' console.error(`导出 ${sessionId} 失败:`, result.error) } @@ -10433,6 +10591,7 @@ class ExportService { pendingSessionIds, successSessionIds, failedSessionIds, + failedSessionErrors, sessionOutputPaths } } @@ -10445,6 +10604,7 @@ class ExportService { pendingSessionIds, successSessionIds, failedSessionIds, + failedSessionErrors, sessionOutputPaths } } @@ -10458,7 +10618,20 @@ class ExportService { }, { force: true }) progressEmitter.flush() - return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths } + const allFailed = successCount === 0 && failCount > 0 + const failureSummary = allFailed + ? Object.values(failedSessionErrors).slice(0, 3).join(';') || '所有会话导出失败' + : undefined + return { + success: !allFailed, + successCount, + failCount, + successSessionIds, + failedSessionIds, + failedSessionErrors, + sessionOutputPaths, + error: failureSummary + } } catch (e) { progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 90c5f81..66552b4 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -81,6 +81,7 @@ export class ImageDecryptService { private pending = new Map>() private updateFlags = new Map() private nativeLogged = false + private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null private datNameScanMissAt = new Map() private readonly datNameScanMissTtlMs = 1200 private readonly accountDirCache = new Map() @@ -99,6 +100,32 @@ export class ImageDecryptService { return this.shouldEmitImageEvents(payload) } + setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void { + this.runtimeConfig = config + } + + private getConfiguredDbPath(): string { + return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() + } + + private getConfiguredMyWxid(): string { + return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() + } + + private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } { + const runtimeImageXorKey = this.runtimeConfig?.imageXorKey + const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== '' + const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim() + if (hasRuntimeXorKey || runtimeAesKey) { + const fallback = this.configService.getImageKeysForCurrentWxid() + return { + xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey, + aesKey: runtimeAesKey || fallback.aesKey + } + } + return this.configService.getImageKeysForCurrentWxid() + } + private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return const timestamp = new Date().toISOString() @@ -266,8 +293,8 @@ export class ImageDecryptService { ) if (normalizedList.length === 0) return - const wxid = this.configService.get('myWxid') - const dbPath = this.configService.get('dbPath') + const wxid = this.getConfiguredMyWxid() + const dbPath = this.getConfiguredDbPath() if (!wxid || !dbPath) return const accountDir = this.resolveAccountDir(dbPath, wxid) @@ -294,8 +321,8 @@ export class ImageDecryptService { this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running') try { - const wxid = this.configService.get('myWxid') - const dbPath = this.configService.get('dbPath') + const wxid = this.getConfiguredMyWxid() + const dbPath = this.getConfiguredDbPath() if (!wxid || !dbPath) { this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失') @@ -404,7 +431,7 @@ export class ImageDecryptService { } // 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置 - const imageKeys = this.configService.getImageKeysForCurrentWxid() + const imageKeys = this.getConfiguredImageKeys() const xorKeyRaw = imageKeys.xorKey // 支持十六进制格式(如 0x53)和十进制格式 let xorKey: number @@ -427,7 +454,7 @@ export class ImageDecryptService { const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : '' const aesKeyForNative = aesKeyText || undefined - this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) }) + this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) }) this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running') const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative) if (!nativeResult) { @@ -527,8 +554,8 @@ export class ImageDecryptService { } private resolveCurrentAccountDir(): string | null { - const wxid = this.configService.get('myWxid') - const dbPath = this.configService.get('dbPath') + const wxid = this.getConfiguredMyWxid() + const dbPath = this.getConfiguredDbPath() if (!wxid || !dbPath) return null return this.resolveAccountDir(dbPath, wxid) } @@ -1551,7 +1578,117 @@ export class ImageDecryptService { }) } } - return result + if (result) return result + const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey) + if (fallback) { + this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext }) + } + return fallback + } + + private tryDecryptDatWithJs( + datPath: string, + xorKey: number, + aesKey?: string + ): { data: Buffer; ext: string; isWxgf: boolean } | null { + try { + const encrypted = readFileSync(datPath) + const directExt = this.detectImageExtension(encrypted) + if (directExt) return { data: encrypted, ext: directExt, isWxgf: false } + + const candidates: Buffer[] = [] + const aesKeyText = String(aesKey || '').trim() + const datVersion = this.getDatVersion(encrypted) + if (datVersion === 2 && aesKeyText.length >= 16) { + try { + candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16))) + } catch { } + } + if (datVersion !== 2) { + candidates.push(this.decryptDatV3WithJs(encrypted, xorKey)) + } + + for (const candidate of candidates) { + const ext = this.detectImageExtension(candidate) + if (ext) return { data: candidate, ext, isWxgf: false } + } + } catch (error) { + this.logError('JS DAT 解密 fallback 失败', error, { datPath }) + } + return null + } + + private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer { + const output = Buffer.allocUnsafe(data.length) + for (let i = 0; i < data.length; i += 1) { + output[i] = data[i] ^ xorKey + } + return output + } + + private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer { + if (data.length < 0x0f) { + throw new Error('dat file too small') + } + const header = data.subarray(0, 0x0f) + const payload = data.subarray(0x0f) + const aesSize = this.readInt32LeSafe(header, 6) + const xorSize = this.readInt32LeSafe(header, 10) + const remainder = ((aesSize % 16) + 16) % 16 + const alignedAesSize = aesSize + (16 - remainder) + if (alignedAesSize > payload.length) throw new Error('invalid aes size') + + const aesData = payload.subarray(0, alignedAesSize) + + let plainAes = Buffer.alloc(0) + if (aesData.length > 0) { + const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0)) + decipher.setAutoPadding(false) + plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()])) + } + + const remaining = payload.subarray(alignedAesSize) + if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size') + + let rawData = Buffer.alloc(0) + let decodedXor = Buffer.alloc(0) + if (xorSize > 0) { + const rawLength = remaining.length - xorSize + if (rawLength < 0) throw new Error('invalid raw size') + rawData = remaining.subarray(0, rawLength) + const xorData = remaining.subarray(rawLength) + decodedXor = Buffer.allocUnsafe(xorData.length) + for (let i = 0; i < xorData.length; i += 1) { + decodedXor[i] = xorData[i] ^ xorKey + } + } else { + rawData = remaining + } + return Buffer.concat([plainAes, rawData, decodedXor]) + } + + private getDatVersion(data: Buffer): number { + if (data.length < 6) return 0 + const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) + const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + if (data.subarray(0, 6).equals(sigV1)) return 1 + if (data.subarray(0, 6).equals(sigV2)) return 2 + return 0 + } + + private readInt32LeSafe(buffer: Buffer, offset: number): number { + if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset') + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24) + } + + private strictRemovePkcs7Padding(data: Buffer): Buffer { + if (data.length === 0) throw new Error('empty decrypted data') + const pad = data[data.length - 1] + if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding') + for (let i = data.length - pad; i < data.length; i += 1) { + if (data[i] !== pad) throw new Error('invalid pkcs7 padding') + } + return data.subarray(0, data.length - pad) } private detectImageExtension(buffer: Buffer): string | null { diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index d5feb7a..3c29db5 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index bc24c25..af13abb 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index cd1799c..33f9cc1 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index c223d45..bf072ff 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index d0829c3..758a1ca 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5192,6 +5192,7 @@ function ExportPage() { exportConcurrency: sourceOptions.exportConcurrency, fileNamingMode: exportDefaultFileNamingMode, sessionLayout, + exportWriteLayout: writeLayout, sessionNameWithTypePrefix, dateRange: sourceOptions.useAllTime ? null @@ -6008,9 +6009,10 @@ function ExportPage() { } return { ...task.template.optionTemplate, + exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout, dateRange } - }, []) + }, [writeLayout]) const enqueueAutomationTask = useCallback(( task: ExportAutomationTask, diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a58e075..1d25a0e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1101,6 +1101,7 @@ export interface ElectronAPI { pendingSessionIds?: string[] successSessionIds?: string[] failedSessionIds?: string[] + failedSessionErrors?: Record sessionOutputPaths?: Record error?: string }> @@ -1269,6 +1270,7 @@ export interface ExportOptions { txtColumns?: string[] fileNamingMode?: 'classic' | 'date-range' sessionLayout?: 'shared' | 'per-session' + exportWriteLayout?: 'A' | 'B' | 'C' sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number