diff --git a/.gitignore b/.gitignore index 123a279..dbde240 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ server/ chatlab-format.md *.bak AGENTS.md +AGENT.md .claude/ CLAUDE.md .agents/ diff --git a/.npmrc b/.npmrc index 9291011..5e1ea93 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ registry=https://registry.npmmirror.com -electron_mirror=https://npmmirror.com/mirrors/electron/ -electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ +electron-mirror=https://npmmirror.com/mirrors/electron/ +electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts new file mode 100644 index 0000000..21ac52d --- /dev/null +++ b/electron/exportWorker.ts @@ -0,0 +1,47 @@ +import { parentPort, workerData } from 'worker_threads' +import { wcdbService } from './services/wcdbService' +import { exportService, ExportOptions } from './services/exportService' + +interface ExportWorkerConfig { + sessionIds: string[] + outputDir: string + options: ExportOptions + resourcesPath?: string + userDataPath?: string + logEnabled?: boolean +} + +const config = workerData as ExportWorkerConfig +process.env.WEFLOW_WORKER = '1' +if (config.resourcesPath) { + process.env.WCDB_RESOURCES_PATH = config.resourcesPath +} + +wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') +wcdbService.setLogEnabled(config.logEnabled === true) + +async function run() { + 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 + }) + } + ) + + parentPort?.postMessage({ + type: 'export:result', + data: result + }) +} + +run().catch((error) => { + parentPort?.postMessage({ + type: 'export:error', + error: String(error) + }) +}) diff --git a/electron/main.ts b/electron/main.ts index bbe351b..b32882c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -17,7 +17,6 @@ import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' import { KeyServiceMac } from './services/keyServiceMac' -import { KeyServiceLinux} from "./services/keyServiceLinux" import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' @@ -96,7 +95,7 @@ let keyService: any if (process.platform === 'darwin') { keyService = new KeyServiceMac() } else if (process.platform === 'linux') { - // const { KeyServiceLinux } = require('./services/keyServiceLinux') + const { KeyServiceLinux } = require('./services/keyServiceLinux') keyService = new KeyServiceLinux() } else { keyService = new KeyService() @@ -1629,7 +1628,7 @@ function registerIpcHandlers() { ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { - event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) + event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text }) }) }) @@ -1641,10 +1640,6 @@ function registerIpcHandlers() { return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) }) - ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => { - return chatService.execQuery(kind, path, sql) - }) - ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) @@ -1856,7 +1851,83 @@ function registerIpcHandlers() { } } - return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + const runMainFallback = async (reason: string) => { + console.warn(`[fallback-export-main] ${reason}`) + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + } + + const cfg = configService || new ConfigService() + configService = cfg + const logEnabled = cfg.get('logEnabled') + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const userDataPath = app.getPath('userData') + const workerPath = join(__dirname, 'exportWorker.js') + + const runWorker = async () => { + return await new Promise((resolve, reject) => { + const worker = new Worker(workerPath, { + workerData: { + sessionIds, + outputDir, + options, + resourcesPath, + userDataPath, + logEnabled + } + }) + + let settled = false + const finalizeResolve = (value: any) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + resolve(value) + } + const finalizeReject = (error: Error) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + reject(error) + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'export:progress') { + onProgress(msg.data as ExportProgress) + return + } + if (msg && msg.type === 'export:result') { + finalizeResolve(msg.data) + return + } + if (msg && msg.type === 'export:error') { + finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败'))) + } + }) + + worker.on('error', (error) => { + finalizeReject(error instanceof Error ? error : new Error(String(error))) + }) + + worker.on('exit', (code) => { + if (settled) return + if (code === 0) { + finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' }) + } else { + finalizeReject(new Error(`导出 Worker 异常退出: ${code}`)) + } + }) + }) + } + + try { + return await runWorker() + } catch (error) { + return runMainFallback(error instanceof Error ? error.message : String(error)) + } }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { diff --git a/electron/preload.ts b/electron/preload.ts index 4cce51c..47fe7ef 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -215,13 +215,11 @@ contextBridge.exposeInMainWorld('electronAPI', { getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), - onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { - const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) + onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => { + const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) }, - execQuery: (kind: string, path: string | null, sql: string) => - ipcRenderer.invoke('chat:execQuery', kind, path, sql), getContacts: () => ipcRenderer.invoke('chat:getContacts'), getMessage: (sessionId: string, localId: number) => ipcRenderer.invoke('chat:getMessage', sessionId, localId), @@ -352,7 +350,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), exportContacts: (outputDir: string, options: any) => ipcRenderer.invoke('export:exportContacts', outputDir, options), - onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => { + onProgress: (callback: (payload: { + current: number + total: number + currentSession: string + currentSessionId?: string + phase: string + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number + }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') } diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 875be7a..1ba6c00 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -68,29 +68,14 @@ class AnalyticsService { return new Set(this.getExcludedUsernamesList()) } - private escapeSqlValue(value: string): string { - return value.replace(/'/g, "''") - } - private async getAliasMap(usernames: string[]): Promise> { const map: Record = {} if (usernames.length === 0) return map - // C++ 层不支持参数绑定,直接内联转义后的字符串值 - const chunkSize = 200 - for (let i = 0; i < usernames.length; i += chunkSize) { - const chunk = usernames.slice(i, i + chunkSize) - const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') - const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})` - const result = await wcdbService.execQuery('contact', null, sql) - if (!result.success || !result.rows) continue - for (const row of result.rows as Record[]) { - const username = row.username || '' - const alias = row.alias || '' - if (username && alias) { - map[username] = alias - } - } + const result = await wcdbService.getContactAliasMap(usernames) + if (!result.success || !result.map) return map + for (const [username, alias] of Object.entries(result.map)) { + if (username && alias) map[username] = alias } return map diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index f91cfc6..e6e0967 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -278,16 +278,16 @@ class AnnualReportService { return cached || null } - const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) { + const result = await wcdbService.getMessageTableColumns(dbPath, tableName) + if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) { this.availableYearsColumnCache.set(cacheKey, '') return null } const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time'] const columns = new Set() - for (const row of result.rows as Record[]) { - const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase() + for (const columnName of result.columns) { + const name = String(columnName || '').trim().toLowerCase() if (name) columns.add(name) } @@ -309,10 +309,11 @@ class AnnualReportService { const tried = new Set() const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => { - const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null - const row = result.rows[0] as Record + const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName) + if (!result.success || !result.data) return null + const row = result.data as Record + const actualColumn = String(row.column || '').trim().toLowerCase() + if (column && actualColumn && column.toLowerCase() !== actualColumn) return null const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs) const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs) return { first, last } diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index f5d17e2..4ba4ed6 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,5 +1,5 @@ import { join, dirname, basename, extname } from 'path' -import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs' +import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' import * as path from 'path' import * as fs from 'fs' import * as https from 'https' @@ -233,12 +233,18 @@ class ChatService { name2IdTable?: string }>() // 缓存会话表信息,避免每次查询 - private sessionTablesCache = new Map>() + private sessionTablesCache = new Map; updatedAt: number }>() private messageTableColumnsCache = new Map; updatedAt: number }>() private messageName2IdTableCache = new Map() private messageSenderIdCache = new Map() private readonly sessionTablesCacheTtl = 300000 // 5分钟 private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 + private messageDbCountSnapshotCache: { + dbPaths: string[] + dbSignature: string + updatedAt: number + } | null = null + private readonly messageDbCountSnapshotCacheTtlMs = 8000 private sessionMessageCountCache = new Map() private sessionMessageCountHintCache = new Map() private sessionMessageCountBatchCache: { @@ -608,11 +614,10 @@ class ChatService { const now = Date.now() for (const username of usernames) { - const state = result.map[username] - if (!state) continue + const state = result.map[username] || { isFolded: false, isMuted: false } this.sessionStatusCache.set(username, { - isFolded: state.isFolded, - isMuted: state.isMuted, + isFolded: Boolean(state.isFolded), + isMuted: Boolean(state.isMuted), updatedAt: now }) } @@ -756,30 +761,6 @@ class ChatService { if (usernames.length === 0) return result try { - const dbPath = this.configService.get('dbPath') - const wxid = this.configService.get('myWxid') - if (!dbPath || !wxid) return result - - const accountDir = this.resolveAccountDir(dbPath, wxid) - if (!accountDir) return result - - // head_image.db 可能在不同位置 - const headImageDbPaths = [ - join(accountDir, 'db_storage', 'head_image', 'head_image.db'), - join(accountDir, 'db_storage', 'head_image.db'), - join(accountDir, 'head_image.db') - ] - - let headImageDbPath: string | null = null - for (const path of headImageDbPaths) { - if (existsSync(path)) { - headImageDbPath = path - break - } - } - - if (!headImageDbPath) return result - const normalizedUsernames = Array.from( new Set( usernames @@ -793,38 +774,20 @@ class ChatService { for (let i = 0; i < normalizedUsernames.length; i += batchSize) { const batch = normalizedUsernames.slice(i, i + batchSize) if (batch.length === 0) continue - const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',') - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})` - ) - if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) { - continue - } + const queryResult = await wcdbService.getHeadImageBuffers(batch) + if (!queryResult.success || !queryResult.map) continue - for (const row of queryResult.rows as any[]) { - const username = String(row?.username || '').trim() - if (!username || !row?.image_buffer) continue - - let base64Data: string | null = null - if (typeof row.image_buffer === 'string') { - // WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64 - if (row.image_buffer.toLowerCase().startsWith('ffd8')) { - const buffer = Buffer.from(row.image_buffer, 'hex') - base64Data = buffer.toString('base64') - } else { - base64Data = row.image_buffer + for (const [username, rawHex] of Object.entries(queryResult.map)) { + const hex = String(rawHex || '').trim() + if (!username || !hex) continue + try { + const base64Data = Buffer.from(hex, 'hex').toString('base64') + if (base64Data) { + result[username] = `data:image/jpeg;base64,${base64Data}` } - } else if (Buffer.isBuffer(row.image_buffer)) { - base64Data = row.image_buffer.toString('base64') - } else if (Array.isArray(row.image_buffer)) { - base64Data = Buffer.from(row.image_buffer).toString('base64') - } - - if (base64Data) { - result[username] = `data:image/jpeg;base64,${base64Data}` + } catch { + // ignore invalid blob hex } } } @@ -867,48 +830,16 @@ class ChatService { return { success: false, error: connectResult.error } } - const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES) - .map((username) => `'${this.escapeSqlString(username)}'`) - .join(',') - - const countsSql = ` - SELECT - SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count, - SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count, - SUM( - CASE - WHEN username NOT LIKE '%@chatroom' - AND username NOT LIKE 'gh_%' - AND local_type = 1 - AND username NOT IN (${excludeExpr}) - THEN 1 ELSE 0 - END - ) AS private_count, - SUM( - CASE - WHEN username NOT LIKE '%@chatroom' - AND username NOT LIKE 'gh_%' - AND local_type = 0 - AND COALESCE(quan_pin, '') != '' - THEN 1 ELSE 0 - END - ) AS former_friend_count - FROM contact - WHERE username IS NOT NULL - AND username != '' - ` - - const result = await wcdbService.execQuery('contact', null, countsSql) - if (!result.success || !result.rows || result.rows.length === 0) { + const result = await wcdbService.getContactTypeCounts() + if (!result.success || !result.counts) { return { success: false, error: result.error || '获取联系人类型数量失败' } } - const row = result.rows[0] as Record const counts: ExportTabCounts = { - private: this.getRowInt(row, ['private_count', 'privateCount'], 0), - group: this.getRowInt(row, ['group_count', 'groupCount'], 0), - official: this.getRowInt(row, ['official_count', 'officialCount'], 0), - former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0) + private: Number(result.counts.private || 0), + group: Number(result.counts.group || 0), + official: Number(result.counts.official || 0), + former_friend: Number(result.counts.former_friend || 0) } return { success: true, counts } @@ -1029,87 +960,20 @@ class ChatService { return { success: true, counts: {}, dbSignature: 'empty' } } - const dbPathsResult = await this.listMessageDbPathsForCount() - if (!dbPathsResult.success) { - return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } + const snapshotResult = await this.getMessageDbCountSnapshot() + const dbPaths = snapshotResult.success ? (snapshotResult.dbPaths || []) : [] + const dbSignature = snapshotResult.success + ? (snapshotResult.dbSignature || this.buildMessageDbSignature(dbPaths)) + : this.buildMessageDbSignature(dbPaths) + const nativeResult = await wcdbService.getSessionMessageCounts(normalizedSessionIds) + if (!nativeResult.success || !nativeResult.counts) { + return { success: false, error: nativeResult.error || '获取会话消息总数失败', dbSignature } } - const dbPaths = dbPathsResult.dbPaths || [] - const dbSignature = this.buildMessageDbSignature(dbPaths) - if (dbPaths.length === 0) { - const emptyCounts = normalizedSessionIds.reduce>((acc, sessionId) => { - acc[sessionId] = 0 - return acc - }, {}) - return { success: true, counts: emptyCounts, dbSignature } - } - - const hashLookup = this.buildSessionHashLookup(normalizedSessionIds) - const counts = normalizedSessionIds.reduce>((acc, sessionId) => { - acc[sessionId] = 0 + const counts = normalizedSessionIds.reduce>((acc, sid) => { + const raw = nativeResult.counts?.[sid] + acc[sid] = Number.isFinite(raw) ? Math.max(0, Math.floor(Number(raw))) : 0 return acc }, {}) - const unionChunkSize = 48 - const queryCountKeys = ['count', 'COUNT(*)', 'cnt', 'CNT', 'table_count', 'tableCount'] - - for (const dbPath of dbPaths) { - const tablesResult = await wcdbService.execQuery( - 'message', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" - ) - if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { - continue - } - - const tableToSessionId = new Map() - for (const row of tablesResult.rows as Record[]) { - const tableName = String(this.getRowField(row, ['name', 'table_name', 'tableName']) || '').trim() - if (!tableName) continue - const sessionId = this.matchSessionIdByTableName(tableName, hashLookup) - if (!sessionId) continue - tableToSessionId.set(tableName, sessionId) - } - - if (tableToSessionId.size === 0) { - continue - } - - const matchedTables = Array.from(tableToSessionId.keys()) - for (let i = 0; i < matchedTables.length; i += unionChunkSize) { - const chunk = matchedTables.slice(i, i + unionChunkSize) - if (chunk.length === 0) continue - - const unionSql = chunk.map((tableName) => { - const tableAlias = tableName.replace(/'/g, "''") - return `SELECT '${tableAlias}' AS table_name, COUNT(*) AS count FROM ${this.quoteSqlIdentifier(tableName)}` - }).join(' UNION ALL ') - - const unionResult = await wcdbService.execQuery('message', dbPath, unionSql) - if (unionResult.success && unionResult.rows) { - for (const row of unionResult.rows as Record[]) { - const tableName = String(this.getRowField(row, ['table_name', 'tableName', 'name']) || '').trim() - const sessionId = tableToSessionId.get(tableName) - if (!sessionId) continue - const countValue = Math.max(0, Math.floor(this.getRowInt(row, queryCountKeys, 0))) - counts[sessionId] = (counts[sessionId] || 0) + countValue - } - continue - } - - // 回退到逐表查询,避免单个 UNION 查询失败导致整批丢失。 - for (const tableName of chunk) { - const sessionId = tableToSessionId.get(tableName) - if (!sessionId) continue - const countSql = `SELECT COUNT(*) AS count FROM ${this.quoteSqlIdentifier(tableName)}` - const singleResult = await wcdbService.execQuery('message', dbPath, countSql) - if (!singleResult.success || !singleResult.rows || singleResult.rows.length === 0) { - continue - } - const countValue = Math.max(0, Math.floor(this.getRowInt(singleResult.rows[0], queryCountKeys, 0))) - counts[sessionId] = (counts[sessionId] || 0) + countValue - } - } - } this.logExportDiag({ traceId, @@ -1214,21 +1078,18 @@ class ChatService { now - cachedBatch.updatedAt <= this.sessionMessageCountBatchCacheTtlMs if (cachedBatchFresh && cachedBatch.sessionIdsKey === sessionIdsKey) { - const dbPathsResult = await this.listMessageDbPathsForCount() - if (dbPathsResult.success) { - const currentDbSignature = this.buildMessageDbSignature(dbPathsResult.dbPaths || []) - if (currentDbSignature === cachedBatch.dbSignature) { - for (const sessionId of pendingSessionIds) { - const nextCountRaw = cachedBatch.counts[sessionId] - const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 - counts[sessionId] = nextCount - this.sessionMessageCountCache.set(sessionId, { - count: nextCount, - updatedAt: now - }) - } - tableScanSucceeded = true + const snapshot = await this.getMessageDbCountSnapshot() + if (snapshot.success && snapshot.dbSignature === cachedBatch.dbSignature) { + for (const sessionId of pendingSessionIds) { + const nextCountRaw = cachedBatch.counts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: now + }) } + tableScanSucceeded = true } } @@ -1340,29 +1201,15 @@ class ChatService { return { success: false, error: connectResult.error } } - // 使用execQuery直接查询加密的contact.db - // kind='contact', path=null表示使用已打开的contact.db - const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, quan_pin - FROM contact - WHERE username IS NOT NULL - AND username != '' - AND ( - username LIKE '%@chatroom' - OR username LIKE 'gh_%' - OR local_type = 1 - OR (local_type = 0 AND COALESCE(quan_pin, '') != '') - ) - ` - const contactResult = await wcdbService.execQuery('contact', null, contactQuery) + const contactResult = await wcdbService.getContactsCompact() - if (!contactResult.success || !contactResult.rows) { + if (!contactResult.success || !contactResult.contacts) { console.error('查询联系人失败:', contactResult.error) return { success: false, error: contactResult.error || '查询联系人失败' } } - const rows = contactResult.rows as Record[] + const rows = contactResult.contacts as Record[] // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -2080,16 +1927,12 @@ class ChatService { private async getFriendIdentitySet(): Promise> { const identities = new Set() - const contactResult = await wcdbService.execQuery( - 'contact', - null, - 'SELECT username, local_type, quan_pin FROM contact' - ) - if (!contactResult.success || !contactResult.rows) { + const contactResult = await wcdbService.getContactsCompact() + if (!contactResult.success || !contactResult.contacts) { return identities } - for (const rowAny of contactResult.rows) { + for (const rowAny of contactResult.contacts) { const row = rowAny as Record const username = String(row.username || '').trim() if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue @@ -2218,7 +2061,9 @@ class ChatService { this.sessionDetailFastCache.clear() this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() + this.sessionTablesCache.clear() this.messageTableColumnsCache.clear() + this.messageDbCountSnapshotCache = null this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) } @@ -2410,6 +2255,13 @@ class ChatService { if (!this.sessionStatsCacheScope) return const normalizedType = String(type || '').toLowerCase() + if ( + normalizedType.includes('message') || + normalizedType.includes('session') || + normalizedType.includes('db') + ) { + this.messageDbCountSnapshotCache = null + } const maybeJson = String(json || '').trim() let ids = new Set() if (maybeJson) { @@ -2472,9 +2324,13 @@ class ChatService { } private async getSessionMessageTables(sessionId: string): Promise> { + const now = Date.now() const cached = this.sessionTablesCache.get(sessionId) - if (cached && cached.length > 0) { - return cached + if (cached && now - cached.updatedAt <= this.sessionTablesCacheTtl && cached.tables.length > 0) { + return cached.tables + } + if (cached) { + this.sessionTablesCache.delete(sessionId) } const tableStats = await wcdbService.getMessageTableStats(sessionId) @@ -2487,8 +2343,10 @@ class ChatService { .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) + this.sessionTablesCache.set(sessionId, { + tables, + updatedAt: now + }) } return tables } @@ -2501,14 +2359,12 @@ class ChatService { return new Set(cached.columns) } - const pragmaSql = `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})` - const result = await wcdbService.execQuery('message', dbPath, pragmaSql) - if (!result.success || !result.rows || result.rows.length === 0) { - return new Set() - } + const result = await wcdbService.getMessageTableColumns(dbPath, tableName) + if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) return new Set() + const columns = new Set() - for (const row of result.rows as Record[]) { - const name = String(this.getRowField(row, ['name', 'column_name', 'columnName']) || '').trim().toLowerCase() + for (const columnName of result.columns) { + const name = String(columnName || '').trim().toLowerCase() if (name) columns.add(name) } this.messageTableColumnsCache.set(cacheKey, { @@ -2717,136 +2573,32 @@ class ChatService { redPacketMessages: 0, callMessages: 0 } - if (sessionId.endsWith('@chatroom')) { + const isGroup = sessionId.endsWith('@chatroom') + if (isGroup) { stats.groupMyMessages = 0 stats.groupActiveSpeakers = 0 } - const tables = await this.getSessionMessageTables(sessionId) - if (tables.length === 0) { - return stats - } - - const senderIdentities = new Set() - let aggregatedTableCount = 0 - const isGroup = sessionId.endsWith('@chatroom') - const escapedSelfKeys = Array.from(selfIdentitySet) - .filter(Boolean) - .map((key) => `'${this.escapeSqlLiteral(key.toLowerCase())}'`) - - for (const { tableName, dbPath } of tables) { - const columnSet = await this.getMessageTableColumns(dbPath, tableName) - if (columnSet.size === 0) continue - - const typeCol = this.pickFirstColumn(columnSet, ['local_type', 'type', 'msg_type', 'msgtype']) - const timeCol = this.pickFirstColumn(columnSet, ['create_time', 'createtime', 'msg_create_time', 'time']) - const senderCol = this.pickFirstColumn(columnSet, ['sender_username', 'senderusername', 'sender']) - const isSendCol = this.pickFirstColumn(columnSet, ['computed_is_send', 'computedissend', 'is_send', 'issend']) - - const selectParts: string[] = [ - 'COUNT(*) AS total_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 34 THEN 1 ELSE 0 END) AS voice_messages` : '0 AS voice_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 3 THEN 1 ELSE 0 END) AS image_messages` : '0 AS image_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 43 THEN 1 ELSE 0 END) AS video_messages` : '0 AS video_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 47 THEN 1 ELSE 0 END) AS emoji_messages` : '0 AS emoji_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 50 THEN 1 ELSE 0 END) AS call_messages` : '0 AS call_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8589934592049 THEN 1 ELSE 0 END) AS transfer_messages` : '0 AS transfer_messages', - typeCol ? `SUM(CASE WHEN ${this.quoteSqlIdentifier(typeCol)} = 8594229559345 THEN 1 ELSE 0 END) AS red_packet_messages` : '0 AS red_packet_messages', - timeCol ? `MIN(${this.quoteSqlIdentifier(timeCol)}) AS first_timestamp` : 'NULL AS first_timestamp', - timeCol ? `MAX(${this.quoteSqlIdentifier(timeCol)}) AS last_timestamp` : 'NULL AS last_timestamp' - ] - - if (isGroup) { - if (senderCol) { - const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))` - if (escapedSelfKeys.length > 0 && isSendCol) { - selectParts.push( - `SUM(CASE WHEN ${normalizedSender} != '' THEN CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END ELSE CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END END) AS group_my_messages` - ) - } else if (escapedSelfKeys.length > 0) { - selectParts.push(`SUM(CASE WHEN ${normalizedSender} IN (${escapedSelfKeys.join(', ')}) THEN 1 ELSE 0 END) AS group_my_messages`) - } else if (isSendCol) { - selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`) - } else { - selectParts.push('0 AS group_my_messages') - } - } else if (isSendCol) { - selectParts.push(`SUM(CASE WHEN ${this.quoteSqlIdentifier(isSendCol)} = 1 THEN 1 ELSE 0 END) AS group_my_messages`) - } else { - selectParts.push('0 AS group_my_messages') - } - - const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}` - const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql) - if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) { - continue - } - - const aggregateRow = aggregateResult.rows[0] as Record - aggregatedTableCount += 1 - stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0) - stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0) - stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0) - stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0) - stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0) - stats.callMessages += this.getRowInt(aggregateRow, ['call_messages', 'callMessages'], 0) - stats.transferMessages += this.getRowInt(aggregateRow, ['transfer_messages', 'transferMessages'], 0) - stats.redPacketMessages += this.getRowInt(aggregateRow, ['red_packet_messages', 'redPacketMessages'], 0) - - const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0) - if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) { - stats.firstTimestamp = firstTs - } - const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0) - if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) { - stats.lastTimestamp = lastTs - } - stats.groupMyMessages = (stats.groupMyMessages || 0) + this.getRowInt(aggregateRow, ['group_my_messages', 'groupMyMessages'], 0) - - if (senderCol) { - const normalizedSender = `LOWER(TRIM(CAST(${this.quoteSqlIdentifier(senderCol)} AS TEXT)))` - const distinctSenderSql = `SELECT DISTINCT ${normalizedSender} AS sender_identity FROM ${this.quoteSqlIdentifier(tableName)} WHERE ${normalizedSender} != ''` - const senderResult = await wcdbService.execQuery('message', dbPath, distinctSenderSql) - if (senderResult.success && senderResult.rows) { - for (const row of senderResult.rows as Record[]) { - const senderIdentity = String(this.getRowField(row, ['sender_identity', 'senderIdentity']) || '').trim() - if (!senderIdentity) continue - senderIdentities.add(senderIdentity) - } - } - } - } else { - const aggregateSql = `SELECT ${selectParts.join(', ')} FROM ${this.quoteSqlIdentifier(tableName)}` - const aggregateResult = await wcdbService.execQuery('message', dbPath, aggregateSql) - if (!aggregateResult.success || !aggregateResult.rows || aggregateResult.rows.length === 0) { - continue - } - const aggregateRow = aggregateResult.rows[0] as Record - aggregatedTableCount += 1 - stats.totalMessages += this.getRowInt(aggregateRow, ['total_messages', 'totalMessages'], 0) - stats.voiceMessages += this.getRowInt(aggregateRow, ['voice_messages', 'voiceMessages'], 0) - stats.imageMessages += this.getRowInt(aggregateRow, ['image_messages', 'imageMessages'], 0) - stats.videoMessages += this.getRowInt(aggregateRow, ['video_messages', 'videoMessages'], 0) - stats.emojiMessages += this.getRowInt(aggregateRow, ['emoji_messages', 'emojiMessages'], 0) - stats.callMessages += this.getRowInt(aggregateRow, ['call_messages', 'callMessages'], 0) - stats.transferMessages += this.getRowInt(aggregateRow, ['transfer_messages', 'transferMessages'], 0) - stats.redPacketMessages += this.getRowInt(aggregateRow, ['red_packet_messages', 'redPacketMessages'], 0) - - const firstTs = this.getRowInt(aggregateRow, ['first_timestamp', 'firstTimestamp'], 0) - if (firstTs > 0 && (stats.firstTimestamp === undefined || firstTs < stats.firstTimestamp)) { - stats.firstTimestamp = firstTs - } - const lastTs = this.getRowInt(aggregateRow, ['last_timestamp', 'lastTimestamp'], 0) - if (lastTs > 0 && (stats.lastTimestamp === undefined || lastTs > stats.lastTimestamp)) { - stats.lastTimestamp = lastTs - } - } - } - - if (aggregatedTableCount === 0) { + const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, 0, 0) + if (!nativeResult.success || !nativeResult.data) { return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet) } + const data = nativeResult.data as Record + stats.totalMessages = Math.max(0, Math.floor(Number(data.total_messages || 0))) + stats.voiceMessages = Math.max(0, Math.floor(Number(data.voice_messages || 0))) + stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0))) + stats.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0))) + stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0))) + stats.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0))) + stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0))) + stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0))) + + const firstTs = Math.max(0, Math.floor(Number(data.first_timestamp || 0))) + const lastTs = Math.max(0, Math.floor(Number(data.last_timestamp || 0))) + if (firstTs > 0) stats.firstTimestamp = firstTs + if (lastTs > 0) stats.lastTimestamp = lastTs + if (preferAccurateSpecialTypes) { try { const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId) @@ -2854,12 +2606,13 @@ class ChatService { stats.redPacketMessages = preciseCounters.redPacketMessages stats.callMessages = preciseCounters.callMessages } catch { - // 保留聚合统计结果作为兜底 + // 保留 native 聚合结果作为兜底 } } if (isGroup) { - stats.groupActiveSpeakers = senderIdentities.size + stats.groupMyMessages = Math.max(0, Math.floor(Number(data.group_my_messages || 0))) + stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(data.group_sender_count || 0))) if (Number.isFinite(stats.groupMyMessages)) { this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) } @@ -2867,6 +2620,64 @@ class ChatService { return stats } + private toExportSessionStatsFromNativeTypeRow(sessionId: string, row: Record): ExportSessionStats { + const stats: ExportSessionStats = { + totalMessages: Math.max(0, Math.floor(Number(row?.total_messages || 0))), + voiceMessages: Math.max(0, Math.floor(Number(row?.voice_messages || 0))), + imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))), + videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))), + emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))), + callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))), + transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))), + redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0))) + } + + const firstTs = Math.max(0, Math.floor(Number(row?.first_timestamp || 0))) + const lastTs = Math.max(0, Math.floor(Number(row?.last_timestamp || 0))) + if (firstTs > 0) stats.firstTimestamp = firstTs + if (lastTs > 0) stats.lastTimestamp = lastTs + + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = Math.max(0, Math.floor(Number(row?.group_my_messages || 0))) + stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(row?.group_sender_count || 0))) + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private async getMessageDbCountSnapshot(forceRefresh = false): Promise<{ + success: boolean + dbPaths?: string[] + dbSignature?: string + error?: string + }> { + const now = Date.now() + if (!forceRefresh && this.messageDbCountSnapshotCache) { + if (now - this.messageDbCountSnapshotCache.updatedAt <= this.messageDbCountSnapshotCacheTtlMs) { + return { + success: true, + dbPaths: [...this.messageDbCountSnapshotCache.dbPaths], + dbSignature: this.messageDbCountSnapshotCache.dbSignature + } + } + } + + const dbPathsResult = await this.listMessageDbPathsForCount() + if (!dbPathsResult.success || !dbPathsResult.dbPaths) { + return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } + } + const dbPaths = dbPathsResult.dbPaths + const dbSignature = this.buildMessageDbSignature(dbPaths) + this.messageDbCountSnapshotCache = { + dbPaths: [...dbPaths], + dbSignature, + updatedAt: now + } + return { success: true, dbPaths, dbSignature } + } + private async buildGroupRelationStats( groupSessionIds: string[], privateSessionIds: string[], @@ -3018,7 +2829,8 @@ class ChatService { const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom')) let memberCountMap: Record = {} - if (groupSessionIds.length > 0) { + const shouldLoadGroupMemberCount = groupSessionIds.length > 0 && (includeRelations || normalizedSessionIds.length === 1) + if (shouldLoadGroupMemberCount) { try { const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} @@ -3054,13 +2866,43 @@ class ChatService { } } + const nativeBatchStats: Record = {} + let hasNativeBatchStats = false + if (!preferAccurateSpecialTypes) { + try { + const quickMode = !includeRelations && normalizedSessionIds.length > 1 + const nativeBatch = await wcdbService.getSessionMessageTypeStatsBatch(normalizedSessionIds, { + beginTimestamp: 0, + endTimestamp: 0, + quickMode, + includeGroupSenderCount: true + }) + if (nativeBatch.success && nativeBatch.data) { + for (const sessionId of normalizedSessionIds) { + const row = nativeBatch.data?.[sessionId] as Record | undefined + if (!row || typeof row !== 'object') continue + nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row) + } + hasNativeBatchStats = Object.keys(nativeBatchStats).length > 0 + } else { + console.warn('[fallback-exec] getSessionMessageTypeStatsBatch failed, fallback to per-session stats path') + } + } catch (error) { + console.warn('[fallback-exec] getSessionMessageTypeStatsBatch exception, fallback to per-session stats path:', error) + } + } + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { try { - const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) + const stats = hasNativeBatchStats && nativeBatchStats[sessionId] + ? { ...nativeBatchStats[sessionId] } + : await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) if (sessionId.endsWith('@chatroom')) { - stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' - ? Math.max(0, Math.floor(memberCountMap[sessionId])) - : 0 + if (shouldLoadGroupMemberCount) { + stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' + ? Math.max(0, Math.floor(memberCountMap[sessionId])) + : 0 + } if (includeRelations) { stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number' ? Math.max(0, Math.floor(groupMutualFriendMap[sessionId])) @@ -4375,24 +4217,6 @@ class ChatService { return candidates } - private async resolveChatNameId(dbPath: string, senderWxid: string): Promise { - const escaped = this.escapeSqlString(senderWxid) - const name2IdTable = await this.resolveName2IdTableName(dbPath) - if (!name2IdTable) return null - const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${name2IdTable}')`) - if (!info.success || !info.rows) return null - const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) - const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) - const column = lower.get('name_id') || lower.get('id') || 'rowid' - const sql = `SELECT ${column} AS id FROM ${name2IdTable} WHERE user_name = '${escaped}' LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (!result.success || !result.rows || result.rows.length === 0) return null - const value = result.rows[0]?.id - if (value === null || value === undefined) return null - const parsed = typeof value === 'number' ? value : parseInt(String(value), 10) - return Number.isFinite(parsed) ? parsed : null - } - private decodeVoiceBlob(raw: any): Buffer | null { if (!raw) return null if (Buffer.isBuffer(raw)) return raw @@ -4415,66 +4239,10 @@ class ChatService { return null } - private async resolveVoiceInfoColumns(dbPath: string, tableName: string): Promise<{ - dataColumn: string; - chatNameIdColumn?: string; - createTimeColumn?: string; - msgLocalIdColumn?: string; - } | null> { - const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${tableName}')`) - if (!info.success || !info.rows) return null - const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) - if (columns.length === 0) return null - const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) - const dataColumn = - lower.get('voice_data') || - lower.get('buf') || - lower.get('voicebuf') || - lower.get('data') - if (!dataColumn) return null - return { - dataColumn, - chatNameIdColumn: lower.get('chat_name_id') || lower.get('chatnameid') || lower.get('chat_nameid'), - createTimeColumn: lower.get('create_time') || lower.get('createtime') || lower.get('time'), - msgLocalIdColumn: lower.get('msg_local_id') || lower.get('msglocalid') || lower.get('localid') - } - } - private escapeSqlString(value: string): string { return value.replace(/'/g, "''") } - private async resolveVoiceInfoTableName(dbPath: string): Promise { - // 1. 优先尝试标准表名 'VoiceInfo' - const checkStandard = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name='VoiceInfo'" - ) - if (checkStandard.success && checkStandard.rows && checkStandard.rows.length > 0) { - return 'VoiceInfo' - } - - // 2. 只有在找不到标准表时,才尝试模糊匹配 (兼容性) - const result = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%' ORDER BY name DESC LIMIT 1" - ) - if (!result.success || !result.rows || result.rows.length === 0) return null - return result.rows[0]?.name || null - } - - private async resolveName2IdTableName(dbPath: string): Promise { - const result = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1" - ) - if (!result.success || !result.rows || result.rows.length === 0) return null - return result.rows[0]?.name || null - } - private async resolveMessageName2IdTableName(dbPath: string): Promise { const normalizedDbPath = String(dbPath || '').trim() if (!normalizedDbPath) return null @@ -4482,6 +4250,7 @@ class ChatService { return this.messageName2IdTableCache.get(normalizedDbPath) || null } + // fallback-exec: 当前缺少按 message.db 反查 Name2Id 表名的专属接口 const result = await wcdbService.execQuery( 'message', normalizedDbPath, @@ -4513,6 +4282,7 @@ class ChatService { } const escapedTableName = String(name2IdTable).replace(/"/g, '""') + // fallback-exec: 当前缺少按 rowid -> user_name 的 message.db 专属接口 const result = await wcdbService.execQuery( 'message', normalizedDbPath, @@ -4884,11 +4654,9 @@ class ChatService { // DLL 有时不返回 alias 字段,补一条直接 SQL 查询兜底 if (!alias) { try { - const safe = username.replace(/'/g, "''") - const sqlResult = await wcdbService.execQuery('contact', null, - `SELECT alias FROM contact WHERE username = '${safe}' LIMIT 1`) - if (sqlResult.success && Array.isArray(sqlResult.rows) && sqlResult.rows.length > 0) { - alias = String(sqlResult.rows[0]?.alias || sqlResult.rows[0]?.Alias || '') + const aliasResult = await wcdbService.getContactAliasMap([username]) + if (aliasResult.success && aliasResult.map && aliasResult.map[username]) { + alias = String(aliasResult.map[username] || '') } } catch { // 兜底失败不影响主流程 @@ -5782,7 +5550,7 @@ class ChatService { } /** - * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) + * getVoiceData(主用批量专属接口读取语音数据) */ async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> { const startTime = Date.now() @@ -5813,13 +5581,12 @@ class ChatService { return { success: false, error: '未找到消息时间戳' } } - // 使用 sessionId + createTime 作为缓存key - const cacheKey = `${sessionId}_${msgCreateTime}` + // 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音 + const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime) // 检查 WAV 内存缓存 const wavCache = this.voiceWavCache.get(cacheKey) if (wavCache) { - return { success: true, data: wavCache.toString('base64') } } @@ -5829,9 +5596,7 @@ class ChatService { if (existsSync(wavFilePath)) { try { const wavData = readFileSync(wavFilePath) - // 同时缓存到内存 this.cacheVoiceWav(cacheKey, wavData) - return { success: true, data: wavData.toString('base64') } } catch (e) { console.error('[Voice] 读取缓存文件失败:', e) @@ -5859,7 +5624,7 @@ class ChatService { const t3 = Date.now() // 从数据库读取 silk 数据 - const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) + const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, serverId || 0, candidates) const t4 = Date.now() @@ -5903,216 +5668,179 @@ class ChatService { private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise { try { const voiceCacheDir = this.getVoiceCacheDir() - if (!existsSync(voiceCacheDir)) { - mkdirSync(voiceCacheDir, { recursive: true }) - } - + await fsPromises.mkdir(voiceCacheDir, { recursive: true }) const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) - writeFileSync(wavFilePath, wavData) + await fsPromises.writeFile(wavFilePath, wavData) } catch (e) { console.error('[Voice] 缓存文件失败:', e) } } /** - * 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口) - * 策略:批量查询 + 多种兜底方案 + * 通过 WCDB 专属接口查询语音数据 + * 策略:批量查询 + 单条 native 兜底 */ - private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise { - const startTime = Date.now() + private async getVoiceDataFromMediaDb( + sessionId: string, + createTime: number, + localId: number, + svrId: string | number, + candidates: string[] + ): Promise { try { - const t1 = Date.now() - // 获取所有 media 数据库(永久缓存,直到应用重启) - let mediaDbFiles: string[] - if (this.mediaDbsCache) { - mediaDbFiles = this.mediaDbsCache - - } else { - const mediaDbsResult = await wcdbService.listMediaDbs() - const t2 = Date.now() - - - let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] - - // Fallback: 如果 WCDB DLL 没找到,手动查找 - if (files.length === 0) { - console.warn('[Voice] listMediaDbs returned empty, trying manual search') - files = await this.findMediaDbsManually() - } - - if (files.length === 0) { - console.error('[Voice] No media DBs found') - return null - } - - mediaDbFiles = files - this.mediaDbsCache = mediaDbFiles // 永久缓存 - } - - // 在所有 media 数据库中查找 - for (const dbPath of mediaDbFiles) { - try { - // 检查缓存 - let schema = this.mediaDbSchemaCache.get(dbPath) - - if (!schema) { - const t3 = Date.now() - // 第一次查询,获取表结构并缓存 - const tablesResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" - ) - const t4 = Date.now() - - - if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { - continue - } - - const voiceTable = tablesResult.rows[0].name - - const t5 = Date.now() - const columnsResult = await wcdbService.execQuery('media', dbPath, - `PRAGMA table_info('${voiceTable}')` - ) - const t6 = Date.now() - - - if (!columnsResult.success || !columnsResult.rows) { - continue - } - - // 创建列名映射(原始名称 -> 小写名称) - const columnMap = new Map() - for (const c of columnsResult.rows) { - const name = String(c.name || '') - if (name) { - columnMap.set(name.toLowerCase(), name) - } - } - - // 查找数据列(使用原始列名) - const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n)) - const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined - - if (!dataColumn) { - continue - } - - // 查找 chat_name_id 列 - const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n)) - const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined - - // 查找时间列 - const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n)) - const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined - - const t7 = Date.now() - // 查找 Name2Id 表 - const name2IdTablesResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" - ) - const t8 = Date.now() - - - const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) - ? name2IdTablesResult.rows[0].name - : undefined - - schema = { - voiceTable, - dataColumn, - chatNameIdColumn, - timeColumn, - name2IdTable - } - - // 缓存表结构 - this.mediaDbSchemaCache.set(dbPath, schema) - } - - // 策略1: 通过 chat_name_id + create_time 查找(最准确) - if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) { - const t9 = Date.now() - // 批量获取所有 candidates 的 chat_name_id(减少查询次数) - const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',') - const name2IdResult = await wcdbService.execQuery('media', dbPath, - `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` - ) - const t10 = Date.now() - - - if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { - // 构建 chat_name_id 列表 - const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid) - const chatNameIdsStr = chatNameIds.join(',') - - const t11 = Date.now() - // 一次查询所有可能的语音 - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` - ) - const t12 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - } - - // 策略2: 只通过 create_time 查找(兜底) - if (schema.timeColumn) { - const t13 = Date.now() - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` - ) - const t14 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - - // 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况) - if (schema.timeColumn) { - const t15 = Date.now() - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` - ) - const t16 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - } catch (e) { - // 静默失败,继续尝试下一个数据库 + const batchResult = await wcdbService.getVoiceDataBatch([{ + session_id: sessionId, + create_time: Math.max(0, Math.floor(Number(createTime || 0))), + local_id: Math.max(0, Math.floor(Number(localId || 0))), + svr_id: svrId || 0, + candidates: Array.isArray(candidates) ? candidates : [] + }]) + if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) { + const hex = String(batchResult.rows[0]?.hex || '').trim() + if (hex) { + const decoded = this.decodeVoiceBlob(hex) + if (decoded && decoded.length > 0) return decoded } } + // fallback-native: 受控回退到旧单条 native 查询 + const single = await wcdbService.getVoiceData( + sessionId, + Math.max(0, Math.floor(Number(createTime || 0))), + Array.isArray(candidates) ? candidates : [], + Math.max(0, Math.floor(Number(localId || 0))), + svrId || 0 + ) + if (single.success && single.hex) { + const decoded = this.decodeVoiceBlob(single.hex) + if (decoded && decoded.length > 0) return decoded + } return null } catch (e) { return null } } + async preloadVoiceDataBatch( + sessionId: string, + messages: Array<{ + localId?: number | string + createTime?: number | string + serverId?: number | string + senderWxid?: string | null + }>, + options?: { chunkSize?: number; decodeConcurrency?: number } + ): Promise<{ success: boolean; prepared?: number; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: true, prepared: 0 } + if (!Array.isArray(messages) || messages.length === 0) return { success: true, prepared: 0 } + + const myWxid = String(this.configService.get('myWxid') || '').trim() + const nowPrepared = new Set() + const pending: Array<{ + cacheKey: string + request: { session_id: string; create_time: number; local_id: number; svr_id: string | number; candidates: string[] } + }> = [] + + for (const item of messages) { + const localId = Math.max(0, Math.floor(Number(item?.localId || 0))) + const createTime = Math.max(0, Math.floor(Number(item?.createTime || 0))) + if (!localId || !createTime) continue + + const cacheKey = this.getVoiceCacheKey(normalizedSessionId, String(localId), createTime) + if (nowPrepared.has(cacheKey)) continue + nowPrepared.add(cacheKey) + + const inMemory = this.voiceWavCache.get(cacheKey) + if (inMemory && inMemory.length > 0) continue + + const wavFilePath = join(this.getVoiceCacheDir(), `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + const wavData = readFileSync(wavFilePath) + if (wavData.length > 0) { + this.cacheVoiceWav(cacheKey, wavData) + continue + } + } catch { + // ignore corrupted cache file + } + } + + const senderWxid = String(item?.senderWxid || '').trim() + const candidates: string[] = [] + if (senderWxid) candidates.push(senderWxid) + if (!candidates.includes(normalizedSessionId)) candidates.push(normalizedSessionId) + if (myWxid && !candidates.includes(myWxid)) candidates.push(myWxid) + + pending.push({ + cacheKey, + request: { + session_id: normalizedSessionId, + create_time: createTime, + local_id: localId, + svr_id: item?.serverId || 0, + candidates + } + }) + } + + if (pending.length === 0) { + return { success: true, prepared: nowPrepared.size } + } + + const chunkSize = Math.max(8, Math.min(128, Math.floor(Number(options?.chunkSize || 48)))) + const decodeConcurrency = Math.max(1, Math.min(6, Math.floor(Number(options?.decodeConcurrency || 3)))) + let prepared = nowPrepared.size - pending.length + + for (let i = 0; i < pending.length; i += chunkSize) { + const chunk = pending.slice(i, i + chunkSize) + const batchResult = await wcdbService.getVoiceDataBatch(chunk.map(item => item.request)) + if (!batchResult.success || !Array.isArray(batchResult.rows)) { + continue + } + + const byIndex = new Map() + for (const row of batchResult.rows as Array>) { + const idx = Number.parseInt(String(row?.index ?? ''), 10) + const hex = String(row?.hex || '').trim() + if (!Number.isFinite(idx) || idx < 0 || !hex) continue + byIndex.set(idx, hex) + } + + const readyItems: Array<{ cacheKey: string; hex: string }> = [] + for (let rowIdx = 0; rowIdx < chunk.length; rowIdx += 1) { + const hex = byIndex.get(rowIdx) + if (!hex) continue + readyItems.push({ cacheKey: chunk[rowIdx].cacheKey, hex }) + } + + await this.forEachWithConcurrency(readyItems, decodeConcurrency, async (item) => { + const silkData = this.decodeVoiceBlob(item.hex) + if (!silkData || silkData.length === 0) return + + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + if (!pcmData || pcmData.length === 0) return + + const wavData = this.createWavBuffer(pcmData, 24000) + this.cacheVoiceWav(item.cacheKey, wavData) + this.cacheVoiceWavToFile(item.cacheKey, wavData) + prepared += 1 + }) + } + + return { success: true, prepared } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 检查语音是否已有缓存(只检查内存,不查询数据库) */ @@ -6141,121 +5869,8 @@ class ChatService { const msgResult = await this.getMessageByLocalId(sessionId, localId) if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' } const msg = msgResult.message - if (msg.isSend === 1) { - console.info('[ChatService][Voice] self-sent voice, continue decrypt flow') - } - - const candidates = this.getVoiceLookupCandidates(sessionId, msg) - if (candidates.length === 0) { - return { success: false, error: '未找到语音关联账号' } - } - console.info('[ChatService][Voice] request', { - sessionId, - localId: msg.localId, - createTime: msg.createTime, - candidates - }) - - // 2. 查找所有的 media_*.db - let mediaDbs = await wcdbService.listMediaDbs() - // Fallback: 如果 WCDB DLL 不支持 listMediaDbs,手动查找 - if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) { - const manualMediaDbs = await this.findMediaDbsManually() - if (manualMediaDbs.length > 0) { - mediaDbs = { success: true, data: manualMediaDbs } - } else { - return { success: false, error: '未找到媒体库文件 (media_*.db)' } - } - } - - // 3. 在所有媒体库中查找该消息的语音数据 - let silkData: Buffer | null = null - for (const dbPath of (mediaDbs.data || [])) { - const voiceTable = await this.resolveVoiceInfoTableName(dbPath) - if (!voiceTable) { - continue - } - const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable) - if (!columns) { - continue - } - for (const candidate of candidates) { - const chatNameId = await this.resolveChatNameId(dbPath, candidate) - // 策略 1: 使用 ChatNameId + CreateTime (最准确) - if (chatNameId) { - let whereClause = '' - if (columns.chatNameIdColumn && columns.createTimeColumn) { - whereClause = `${columns.chatNameIdColumn} = ${chatNameId} AND ${columns.createTimeColumn} = ${msg.createTime}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by createTime', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - break - } - } - } - } - - // 策略 2: 使用 MsgLocalId (兜底,如果表支持) - if (columns.msgLocalIdColumn) { - const whereClause = `${columns.msgLocalIdColumn} = ${msg.localId}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by localId', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - break - } - } - } - } - if (silkData) break - - // 策略 3: 只使用 CreateTime (兜底) - if (!silkData && columns.createTimeColumn) { - const whereClause = `${columns.createTimeColumn} = ${msg.createTime}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - } - } - } - if (silkData) break - } - - if (!silkData) return { success: false, error: '未找到语音数据' } - - // 4. 使用 silk-wasm 解码 - try { - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - if (!pcmData) { - return { success: false, error: 'Silk 解码失败' } - } - - // PCM -> WAV - const wavData = this.createWavBuffer(pcmData, 24000) - - // 缓存 WAV 数据 (内存缓存) - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - this.cacheVoiceWav(cacheKey, wavData) - - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] decoding error:', e) - return { success: false, error: '语音解码失败: ' + String(e) } - } + const senderWxid = msg.senderUsername || undefined + return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverId, senderWxid) } catch (e) { console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } @@ -6449,9 +6064,9 @@ class ChatService { private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string { - // 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱 + // createTime + msgId 可避免同会话同秒多条语音互相覆盖 if (createTime) { - return `${sessionId}_${createTime}` + return `${sessionId}_${createTime}_${msgId}` } return `${sessionId}_${msgId}` } @@ -6546,10 +6161,10 @@ class ChatService { for (const key of this.voiceTranscriptCache.keys()) { const rawKey = String(key || '') if (!rawKey) continue - // cacheKey 形如 `${sessionId}_${createTime}`,createTime 为数字;兼容旧 key 时使用贪婪匹配。 - const match = /^(.*)_(\d+)$/.exec(rawKey) - if (!match) continue - const sessionId = String(match[1] || '').trim() + // 新 key: `${sessionId}_${createTime}_${msgId}`;旧 key: `${sessionId}_${createTime}` + const matchNew = /^(.*)_(\d+)_(\d+)$/.exec(rawKey) + const matchOld = matchNew ? null : /^(.*)_(\d+)$/.exec(rawKey) + const sessionId = String((matchNew ? matchNew[1] : (matchOld ? matchOld[1] : '')) || '').trim() if (!sessionId || !targetSet.has(sessionId)) continue countMap[sessionId] = (countMap[sessionId] || 0) + 1 } @@ -6567,36 +6182,12 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - // 获取会话表信息 - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) - } + const result = await wcdbService.getMessagesByType(sessionId, 34, false, 0, 0) + if (!result.success || !Array.isArray(result.rows)) { + return { success: false, error: result.error || '查询语音消息失败' } } - let allVoiceMessages: Message[] = [] - - for (const { tableName, dbPath } of tables) { - try { - const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const mapped = this.mapRowsToMessages(result.rows as Record[]) - allVoiceMessages.push(...mapped) - } - } catch (e) { - console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e) - } - } + let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record[]) // 按 createTime 降序排序 allVoiceMessages.sort((a, b) => b.createTime - a.createTime) @@ -6634,43 +6225,20 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) - } + const result = await wcdbService.getMessagesByType(sessionId, 3, false, 0, 0) + if (!result.success || !Array.isArray(result.rows)) { + return { success: false, error: result.error || '查询图片消息失败' } } - let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = [] - - for (const { tableName, dbPath } of tables) { - try { - const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const mapped = this.mapRowsToMessages(result.rows as Record[]) - const images = mapped - .filter(msg => msg.localType === 3) - .map(msg => ({ - imageMd5: msg.imageMd5 || undefined, - imageDatName: msg.imageDatName || undefined, - createTime: msg.createTime || undefined - })) - .filter(img => Boolean(img.imageMd5 || img.imageDatName)) - allImages.push(...images) - } - } catch (e) { - console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e) - } - } + const mapped = this.mapRowsToMessages(result.rows as Record[]) + let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped + .filter(msg => msg.localType === 3) + .map(msg => ({ + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName || undefined, + createTime: msg.createTime || undefined + })) + .filter(img => Boolean(img.imageMd5 || img.imageDatName)) allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0)) @@ -6719,50 +6287,11 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { - this.sessionTablesCache.delete(sessionId) - }, this.sessionTablesCacheTtl) - } - } - - const counts: Record = {} - let hasAnySuccess = false - - for (const { tableName, dbPath } of tables) { - try { - const escapedTableName = String(tableName).replace(/"/g, '""') - const sql = `SELECT strftime('%Y-%m-%d', CASE WHEN create_time > 10000000000 THEN create_time / 1000 ELSE create_time END, 'unixepoch', 'localtime') AS date_key, COUNT(*) AS message_count FROM "${escapedTableName}" WHERE create_time IS NOT NULL GROUP BY date_key` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (!result.success || !Array.isArray(result.rows)) { - console.warn(`[ChatService] 查询每日消息数失败 (${dbPath}):`, result.error) - continue - } - hasAnySuccess = true - result.rows.forEach((row: Record) => { - const date = String(row.date_key || '').trim() - const count = Number(row.message_count || 0) - if (!date || !Number.isFinite(count) || count <= 0) return - counts[date] = (counts[date] || 0) + count - }) - } catch (error) { - console.warn(`[ChatService] 聚合每日消息数失败 (${dbPath}):`, error) - } - } - - if (!hasAnySuccess) { - return { success: false, error: '查询每日消息数失败' } + const result = await wcdbService.getSessionMessageDateCounts(sessionId) + if (!result.success || !result.counts) { + return { success: false, error: result.error || '查询每日消息数失败' } } + const counts = result.counts console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`) return { success: true, counts } @@ -6774,54 +6303,12 @@ class ChatService { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - // 1. 尝试从缓存获取会话表信息 - let tables = this.sessionTablesCache.get(sessionId) - - if (!tables) { - // 缓存未命中,查询数据库 - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - - // 提取表信息并缓存 - tables = tableStats.tables - .map(t => ({ - tableName: t.table_name || t.name, - dbPath: t.db_path - })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - // 设置过期清理 - setTimeout(() => { - this.sessionTablesCache.delete(sessionId) - }, this.sessionTablesCacheTtl) - } + const nativeResult = await wcdbService.getMessageById(sessionId, localId) + if (nativeResult.success && nativeResult.message) { + const message = await this.parseMessage(nativeResult.message as Record, { source: 'detail', sessionId }) + if (message.localId !== 0) return { success: true, message } } - - // 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) - for (const { tableName, dbPath } of tables) { - // 构造查询 - const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` - const result = await wcdbService.execQuery('message', dbPath, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = { - ...(result.rows[0] as Record), - db_path: dbPath, - table_name: tableName - } - const message = await this.parseMessage(row, { source: 'detail', sessionId }) - - if (message.localId !== 0) { - return { success: true, message } - } - } - } - - return { success: false, error: '未找到消息' } + return { success: false, error: nativeResult.error || '未找到消息' } } catch (e) { console.error('ChatService: getMessageById 失败:', e) return { success: false, error: String(e) } @@ -7218,6 +6705,7 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + // fallback-exec: 仅用于诊断/低频兼容,不作为业务主路径 return wcdbService.execQuery(kind, path, sql) } catch (e) { console.error('ChatService: 执行自定义查询失败:', e) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index fd68a25..16b0d53 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -128,6 +128,10 @@ export interface ExportProgress { phaseProgress?: number phaseTotal?: number phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number } interface ExportTaskControl { @@ -350,6 +354,73 @@ class ExportService { return Math.max(1, Math.min(raw, max)) } + private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): { + emit: (progress: ExportProgress, options?: { force?: boolean }) => void + flush: () => void + } { + if (!onProgress) { + return { + emit: () => { /* noop */ }, + flush: () => { /* noop */ } + } + } + + let pending: ExportProgress | null = null + let lastSentAt = 0 + let lastPhase = '' + let lastSessionId = '' + let lastCollected = 0 + let lastExported = 0 + + const commit = (progress: ExportProgress) => { + onProgress(progress) + pending = null + lastSentAt = Date.now() + lastPhase = String(progress.phase || '') + lastSessionId = String(progress.currentSessionId || '') + lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected + lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported + } + + const emit = (progress: ExportProgress, options?: { force?: boolean }) => { + pending = progress + const force = options?.force === true + const now = Date.now() + const phase = String(progress.phase || '') + const sessionId = String(progress.currentSessionId || '') + const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected + const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported + const collectedDelta = Math.abs(collected - lastCollected) + const exportedDelta = Math.abs(exported - lastExported) + const shouldEmit = force || + phase !== lastPhase || + sessionId !== lastSessionId || + collectedDelta >= 200 || + exportedDelta >= 200 || + (now - lastSentAt >= 120) + + if (shouldEmit && pending) { + commit(pending) + } + } + + const flush = () => { + if (!pending) return + commit(pending) + } + + return { emit, flush } + } + + private async pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK) + return true + } catch { + return false + } + } + private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) @@ -428,7 +499,8 @@ class ExportService { total: 100, currentSession: sessionName, phase: 'preparing', - phaseLabel: `收集消息 ${fetched.toLocaleString()} 条` + phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`, + collectedMessages: fetched }) } } @@ -464,6 +536,39 @@ class ExportService { return cleaned } + private getIntFromRow(row: Record, keys: string[], fallback = 0): number { + for (const key of keys) { + const raw = row?.[key] + if (raw === undefined || raw === null || raw === '') continue + const parsed = Number.parseInt(String(raw), 10) + if (Number.isFinite(parsed)) return parsed + } + return fallback + } + + private normalizeUnsignedIntToken(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '0' + if (/^\d+$/.test(raw)) { + return raw.replace(/^0+(?=\d)/, '') + } + const num = Number(raw) + if (!Number.isFinite(num) || num <= 0) return '0' + return String(Math.floor(num)) + } + + private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + const localId = this.normalizeUnsignedIntToken(msg?.localId) + const createTime = this.normalizeUnsignedIntToken(msg?.createTime) + const serverId = this.normalizeUnsignedIntToken(msg?.serverId) + return `${localId}:${createTime}:${serverId}` + } + + private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + const localType = this.normalizeUnsignedIntToken(msg?.localType) + return `${localType}_${this.getStableMessageKey(msg)}` + } + private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -577,13 +682,11 @@ class ExportService { } try { - const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' - const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) - if (!result.success || !result.rows || result.rows.length === 0) { + const result = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (!result.success || !result.extBuffer) { return nicknameMap } - - const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) + const extBuffer = this.decodeExtBuffer(result.extBuffer) if (!extBuffer) return nicknameMap this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) return nicknameMap @@ -2162,13 +2265,14 @@ class ExportService { exportEmojis?: boolean exportVoiceAsText?: boolean includeVoiceWithTranscript?: boolean + dirCache?: Set } ): Promise { const localType = msg.localType // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) + const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) if (result) { } return result @@ -2177,7 +2281,7 @@ class ExportService { // 语音消息 if (localType === 34) { if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) } if (options.exportVoiceAsText) { return null @@ -2186,14 +2290,14 @@ class ExportService { // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) if (result) { } return result } if (localType === 43 && options.exportVideos) { - return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) } return null @@ -2206,12 +2310,14 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') - if (!fs.existsSync(imagesDir)) { - fs.mkdirSync(imagesDir, { recursive: true }) + if (!dirCache?.has(imagesDir)) { + await fs.promises.mkdir(imagesDir, { recursive: true }) + dirCache?.add(imagesDir) } // 使用消息对象中已提取的字段 @@ -2268,7 +2374,7 @@ class ExportService { const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) + await fs.promises.writeFile(destPath, Buffer.from(base64Data, 'base64')) return { relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), @@ -2279,17 +2385,14 @@ class ExportService { } // 复制文件 - if (!fs.existsSync(sourcePath)) { + if (!(await this.pathExists(sourcePath))) { console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) return null } const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } + await fs.promises.copyFile(sourcePath, destPath) return { relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), @@ -2301,6 +2404,56 @@ class ExportService { } } + /** + * 导出语音文件 + */ + private async preloadVoiceWavCache( + sessionId: string, + messages: any[], + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const normalized: Array<{ + localId: number + createTime: number + serverId?: string | number + senderWxid?: string | null + }> = [] + const seen = new Set() + + for (const msg of messages) { + const localIdRaw = Number(msg?.localId) + const createTimeRaw = Number(msg?.createTime) + const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0 + const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0 + if (!localId || !createTime) continue + const dedupeKey = this.getStableMessageKey(msg) + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + normalized.push({ + localId, + createTime, + serverId: msg?.serverId, + senderWxid: msg?.senderUsername || null + }) + } + if (normalized.length === 0) return + + const chunkSize = 120 + for (let i = 0; i < normalized.length; i += chunkSize) { + this.throwIfStopRequested(control) + const chunk = normalized.slice(i, i + chunkSize) + await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, { + chunkSize: 48, + decodeConcurrency: 3 + }) + } + } + /** * 导出语音文件 */ @@ -2308,23 +2461,26 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') - if (!fs.existsSync(voicesDir)) { - fs.mkdirSync(voicesDir, { recursive: true }) + if (!dirCache?.has(voicesDir)) { + await fs.promises.mkdir(voicesDir, { recursive: true }) + dirCache?.add(voicesDir) } const msgId = String(msg.localId) const safeSession = this.cleanAccountDirName(sessionId) .replace(/[^a-zA-Z0-9_-]/g, '_') .slice(0, 48) || 'session' - const fileName = `voice_${safeSession}_${msgId}.wav` + const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_') + const fileName = `voice_${safeSession}_${stableKey || msgId}.wav` const destPath = path.join(voicesDir, fileName) // 如果已存在则跳过 - if (fs.existsSync(destPath)) { + if (await this.pathExists(destPath)) { return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' @@ -2332,14 +2488,20 @@ class ExportService { } // 调用 chatService 获取语音数据 - const voiceResult = await chatService.getVoiceData(sessionId, msgId) + const voiceResult = await chatService.getVoiceData( + sessionId, + msgId, + Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined, + msg?.serverId, + msg?.senderUsername || undefined + ) if (!voiceResult.success || !voiceResult.data) { return null } // voiceResult.data 是 base64 编码的 wav 数据 const wavBuffer = Buffer.from(voiceResult.data, 'base64') - fs.writeFileSync(destPath, wavBuffer) + await fs.promises.writeFile(destPath, wavBuffer) return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), @@ -2372,18 +2534,20 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') - if (!fs.existsSync(emojisDir)) { - fs.mkdirSync(emojisDir, { recursive: true }) + if (!dirCache?.has(emojisDir)) { + await fs.promises.mkdir(emojisDir, { recursive: true }) + dirCache?.add(emojisDir) } // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) const localPath = await chatService.downloadEmojiFile(msg) - if (!localPath || !fs.existsSync(localPath)) { + if (!localPath || !(await this.pathExists(localPath))) { return null } @@ -2393,10 +2557,7 @@ class ExportService { const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - // 复制文件到导出目录 (如果不存在) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(localPath, destPath) - } + await fs.promises.copyFile(localPath, destPath) return { relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), @@ -2415,15 +2576,17 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const videoMd5 = msg.videoMd5 if (!videoMd5) return null const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') - if (!fs.existsSync(videosDir)) { - fs.mkdirSync(videosDir, { recursive: true }) + if (!dirCache?.has(videosDir)) { + await fs.promises.mkdir(videosDir, { recursive: true }) + dirCache?.add(videosDir) } const videoInfo = await videoService.getVideoInfo(videoMd5) @@ -2435,9 +2598,7 @@ class ExportService { const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } + await fs.promises.copyFile(sourcePath, destPath) return { relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), @@ -2707,12 +2868,19 @@ class ExportService { if ((rowIndex++ & 0x7f) === 0) { this.throwIfStopRequested(control) } - const createTime = parseInt(row.create_time || '0', 10) + const createTime = this.getIntFromRow(row, [ + 'create_time', 'createTime', 'createtime', + 'msg_create_time', 'msgCreateTime', + 'msg_time', 'msgTime', 'time', + 'WCDB_CT_create_time' + ], 0) if (dateRange) { if (createTime < dateRange.start || createTime > dateRange.end) continue } - const localType = parseInt(row.local_type || row.type || '1', 10) + const localType = this.getIntFromRow(row, [ + 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' + ], 1) if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { continue } @@ -2725,7 +2893,18 @@ class ExportService { const senderUsername = row.sender_username || '' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSend = parseInt(isSendRaw, 10) === 1 - const localId = parseInt(row.local_id || row.localId || '0', 10) + const localId = this.getIntFromRow(row, [ + 'local_id', 'localId', 'LocalId', + 'msg_local_id', 'msgLocalId', 'MsgLocalId', + 'msg_id', 'msgId', 'MsgId', 'id', + 'WCDB_CT_local_id' + ], 0) + const serverId = this.getIntFromRow(row, [ + 'server_id', 'serverId', 'ServerId', + 'msg_server_id', 'msgServerId', 'MsgServerId', + 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', + 'WCDB_CT_server_id' + ], 0) // 确定实际发送者 let actualSender: string @@ -2809,6 +2988,7 @@ class ExportService { rows.push({ localId, + serverId, createTime, localType, content, @@ -3073,18 +3253,12 @@ class ExportService { ) if (unique.length === 0) return result - const BATCH = 200 - for (let i = 0; i < unique.length; i += BATCH) { - const batch = unique.slice(i, i + BATCH) - const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT username, local_type FROM contact WHERE username IN (${inList})` - const query = await wcdbService.execQuery('contact', null, sql) - if (!query.success || !query.rows) continue - for (const row of query.rows) { - const username = String((row as any).username || '').trim() - if (!username) continue - const localType = Number.parseInt(String((row as any).local_type ?? (row as any).localType ?? (row as any).WCDB_CT_local_type ?? ''), 10) - result.set(username, Number.isFinite(localType) && localType === 1) + const query = await wcdbService.getContactFriendFlags(unique) + if (query.success && query.map) { + for (const [username, isFriend] of Object.entries(query.map)) { + const normalized = String(username || '').trim() + if (!normalized) continue + result.set(normalized, Boolean(isFriend)) } } @@ -3396,9 +3570,10 @@ class ExportService { collectProgressReporter ) const allMessages = collected.rows + const totalMessages = allMessages.length // 如果没有消息,不创建文件 - if (allMessages.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -3466,8 +3641,14 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 20, total: 100, @@ -3475,7 +3656,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + estimatedTotalMessages: totalMessages }) // 并行导出媒体,并发数跟随导出设置 @@ -3483,14 +3665,15 @@ class ExportService { let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3510,9 +3693,11 @@ class ExportService { } // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 40, total: 100, @@ -3520,7 +3705,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) // 并行转写语音,限制 4 个并发(转写比较耗资源) @@ -3529,7 +3715,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 40, @@ -3548,7 +3734,10 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const chatLabMessages: ChatLabMessage[] = [] @@ -3591,11 +3780,11 @@ class ExportService { // 确定消息内容 let content: string | null - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 - content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } else if (mediaItem && msg.localType === 3) { content = mediaItem.relativePath } else { @@ -3730,6 +3919,18 @@ class ExportService { } chatLabMessages.push(message) + if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) { + const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20) + onProgress?.({ + current: exportProgress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: chatLabMessages.length + }) + } } const avatarMap = options.exportAvatars @@ -3780,7 +3981,10 @@ class ExportService { current: 80, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) if (options.format === 'chatlab-jsonl') { @@ -3799,17 +4003,21 @@ class ExportService { lines.push(JSON.stringify({ _type: 'message', ...message })) } this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') } else { this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') + await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') } onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -3872,9 +4080,10 @@ class ExportService { control, collectProgressReporter ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -3915,8 +4124,14 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 15, total: 100, @@ -3924,21 +4139,23 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3958,9 +4175,11 @@ class ExportService { } // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 35, total: 100, @@ -3968,7 +4187,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -3976,7 +4196,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 35, @@ -4007,7 +4227,10 @@ class ExportService { current: 55, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const allMessages: any[] = [] @@ -4030,11 +4253,11 @@ class ExportService { const source = sourceMatch ? sourceMatch[0] : '' let content: string | null - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { - content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } else if (mediaItem) { content = mediaItem.relativePath } else { @@ -4124,6 +4347,18 @@ class ExportService { allMessages.push(msgObj) if (msg.createTime < lastCreateTime) needSort = true lastCreateTime = msg.createTime + if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) { + const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15) + onProgress?.({ + current: exportProgress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: allMessages.length + }) + } } if (transferCandidates.length > 0) { @@ -4172,7 +4407,10 @@ class ExportService { current: 70, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) // 获取会话的昵称和备注信息 @@ -4421,7 +4659,7 @@ class ExportService { } this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') + await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') } else { const detailedExport: any = { weflow, @@ -4444,14 +4682,18 @@ class ExportService { } this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') } onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -4519,9 +4761,10 @@ class ExportService { control, collectProgressReporter ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -4548,7 +4791,10 @@ class ExportService { current: 30, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // 创建 Excel 工作簿 @@ -4685,8 +4931,14 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 35, total: 100, @@ -4694,21 +4946,23 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -4728,9 +4982,11 @@ class ExportService { } // ========== 并行预处理:语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 50, total: 100, @@ -4738,7 +4994,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -4746,7 +5003,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 50, @@ -4760,15 +5017,41 @@ class ExportService { }) } + const shouldUseStreamingWriter = totalMessages > 20000 + if (shouldUseStreamingWriter) { + return this.exportSessionToExcelStreaming({ + outputPath, + options, + sessionId, + sessionInfo, + myInfo, + cleanedMyWxid, + rawMyWxid, + isGroup, + sortedMessages, + mediaCache, + voiceTranscriptMap, + getContactCached, + groupNicknamesMap, + onProgress, + control, + totalMessages + }) + } + onProgress?.({ current: 65, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // ========== 写入 Excel 行 ========== - for (let i = 0; i < sortedMessages.length; i++) { + const senderProfileCache = new Map() + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } @@ -4782,14 +5065,19 @@ class ExportService { let senderGroupNickname: string = '' // 群昵称 if (isGroup) { - const senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } senderWxid = senderProfile.wxid senderNickname = senderProfile.nickname senderRemark = senderProfile.remark @@ -4819,7 +5107,7 @@ class ExportService { const row = worksheet.getRow(currentRow) row.height = 24 - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText const contentValue = shouldUseTranscript @@ -4827,7 +5115,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -4837,7 +5125,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -4883,14 +5171,6 @@ class ExportService { worksheet.getCell(currentRow, 9).value = enrichedContentValue } - // 设置每个单元格的样式 - const maxColumns = useCompactColumns ? 5 : 9 - for (let col = 1; col <= maxColumns; col++) { - const cell = worksheet.getCell(currentRow, col) - cell.font = { name: 'Calibri', size: 11 } - cell.alignment = { vertical: 'middle', wrapText: false } - } - currentRow++ // 每处理 100 条消息报告一次进度 @@ -4900,7 +5180,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -4909,7 +5192,10 @@ class ExportService { current: 90, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) // 写入文件 @@ -4920,7 +5206,11 @@ class ExportService { current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -4939,6 +5229,236 @@ class ExportService { } } + private async exportSessionToExcelStreaming(params: { + outputPath: string + options: ExportOptions + sessionId: string + sessionInfo: { displayName: string } + myInfo: { displayName: string } + cleanedMyWxid: string + rawMyWxid: string + isGroup: boolean + sortedMessages: any[] + mediaCache: Map + voiceTranscriptMap: Map + getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + onProgress?: (progress: ExportProgress) => void + control?: ExportTaskControl + totalMessages: number + }): Promise<{ success: boolean; error?: string }> { + const { + outputPath, + options, + sessionId, + sessionInfo, + myInfo, + cleanedMyWxid, + rawMyWxid, + isGroup, + sortedMessages, + mediaCache, + voiceTranscriptMap, + getContactCached, + groupNicknamesMap, + onProgress, + control, + totalMessages + } = params + + try { + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ + filename: outputPath, + useStyles: true, + useSharedStrings: false + }) + const worksheet = workbook.addWorksheet('聊天记录') + const useCompactColumns = options.excelCompactColumns === true + const senderProfileCache = new Map() + + worksheet.columns = useCompactColumns + ? [ + { width: 8 }, + { width: 20 }, + { width: 18 }, + { width: 12 }, + { width: 50 } + ] + : [ + { width: 8 }, + { width: 20 }, + { width: 18 }, + { width: 25 }, + { width: 18 }, + { width: 18 }, + { width: 15 }, + { width: 12 }, + { width: 50 } + ] + + const appendRow = (values: any[]) => { + const row = worksheet.addRow(values) + row.commit() + } + + appendRow(['会话信息']) + appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId]) + appendRow(['导出工具', 'WeFlow', '导出时间', this.formatTimestamp(Math.floor(Date.now() / 1000))]) + appendRow([]) + appendRow(useCompactColumns + ? ['序号', '时间', '发送者身份', '消息类型', '内容'] + : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']) + + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) this.throwIfStopRequested(control) + const msg = sortedMessages[i] + + let senderRole: string + let senderWxid: string + let senderNickname: string + let senderRemark = '' + let senderGroupNickname = '' + + if (isGroup) { + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderGroupNickname = senderProfile.groupNickname + senderRole = senderProfile.displayName + } else if (msg.isSend) { + senderRole = '我' + senderWxid = cleanedMyWxid + senderNickname = myInfo.displayName || cleanedMyWxid + } else { + senderWxid = sessionId + const contactDetail = await getContactCached(sessionId) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || sessionId + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = sessionInfo.displayName || sessionId + senderRole = senderNickname + } + } + + const mediaKey = this.getMediaCacheKey(msg) + const mediaItem = mediaCache.get(mediaKey) + const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText + const contentValue = shouldUseTranscript + ? this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(this.getStableMessageKey(msg)), + cleanedMyWxid, + msg.senderUsername, + msg.isSend + ) + : (mediaItem?.relativePath + || this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(this.getStableMessageKey(msg)), + cleanedMyWxid, + msg.senderUsername, + msg.isSend + )) + + let enrichedContentValue = contentValue + if (this.isTransferExportContent(contentValue) && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const c = await getContactCached(username) + if (c.success && c.contact) { + return c.contact.remark || c.contact.nickName || c.contact.alias || username + } + return username + } + ) + if (transferDesc) { + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) + } + } + + appendRow(useCompactColumns + ? [ + i + 1, + this.formatTimestamp(msg.createTime), + senderRole, + this.getMessageTypeName(msg.localType), + enrichedContentValue + ] + : [ + i + 1, + this.formatTimestamp(msg.createTime), + senderNickname, + senderWxid, + senderRemark, + senderGroupNickname, + senderRole, + this.getMessageTypeName(msg.localType), + enrichedContentValue + ]) + + if ((i + 1) % 200 === 0) { + onProgress?.({ + current: 65 + Math.floor((i + 1) / totalMessages * 25), + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 + }) + } + } + + worksheet.commit() + await workbook.commit() + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 + }) + + return { success: true } + } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } + if (e instanceof Error) { + if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { + return { success: false, error: '文件已经打开,请关闭后再导出' } + } + } + return { success: false, error: String(e) } + } + } + /** * 确保语音转写模型已下载 */ @@ -5024,9 +5544,10 @@ class ExportService { control, collectProgressReporter ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -5076,8 +5597,14 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 25, total: 100, @@ -5085,21 +5612,23 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -5118,9 +5647,11 @@ class ExportService { }) } - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 45, total: 100, @@ -5128,7 +5659,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -5136,7 +5668,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 45, @@ -5154,17 +5686,21 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const lines: string[] = [] + const senderProfileCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText const contentValue = shouldUseTranscript @@ -5172,7 +5708,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -5182,7 +5718,7 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, msg.isSend @@ -5214,14 +5750,19 @@ class ExportService { let senderRemark = '' if (isGroup) { - const senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } senderWxid = senderProfile.wxid senderNickname = senderProfile.nickname senderRemark = senderProfile.remark @@ -5253,7 +5794,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -5262,17 +5806,24 @@ class ExportService { current: 92, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -5334,7 +5885,8 @@ class ExportService { control, collectProgressReporter ) - if (collected.rows.length === 0) { + const totalMessages = collected.rows.length + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -5383,8 +5935,14 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 25, total: 100, @@ -5392,14 +5950,15 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, @@ -5425,9 +5984,11 @@ class ExportService { }) } - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 45, total: 100, @@ -5435,7 +5996,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -5443,7 +6005,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 45, @@ -5461,18 +6023,22 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const lines: string[] = [] lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + const senderProfileCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) || null const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '') @@ -5485,14 +6051,19 @@ class ExportService { let talker = myInfo.displayName || '我' if (isGroup) { - const senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : senderWxid, - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : senderWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } talker = senderProfile.displayName } else if (!msg.isSend) { const contactDetail = await getContactCached(senderWxid) @@ -5515,7 +6086,7 @@ class ExportService { } const msgText = msg.localType === 34 && options.exportVoiceAsText - ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') + ? (voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]') : (this.parseMessageContent( msg.content, msg.localType, @@ -5546,7 +6117,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -5555,17 +6129,24 @@ class ExportService { current: 92, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) this.throwIfStopRequested(control) - fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') + await fs.promises.writeFile(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } @@ -5763,6 +6344,11 @@ class ExportService { const mediaCache = new Map() if (mediaMessages.length > 0) { + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 20, total: 100, @@ -5770,14 +6356,15 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + estimatedTotalMessages: totalMessages }) const MEDIA_CONCURRENCY = 6 let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, @@ -5785,7 +6372,8 @@ class ExportService { exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, includeVoiceWithTranscript: true, - exportVideos: options.exportVideos + exportVideos: options.exportVideos, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -5808,9 +6396,11 @@ class ExportService { const voiceMessages = useVoiceTranscript ? sortedMessages.filter(msg => msg.localType === 34) : [] - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 40, total: 100, @@ -5818,7 +6408,8 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 @@ -5826,7 +6417,7 @@ class ExportService { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 40, @@ -5858,7 +6449,10 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // ================= BEGIN STREAM WRITING ================= @@ -5919,6 +6513,7 @@ class ExportService { // Pre-build avatar HTML lookup to avoid per-message rebuilds const avatarHtmlCache = new Map() + const senderProfileCache = new Map() const getAvatarHtml = (username: string, name: string): string => { const cached = avatarHtmlCache.get(username) if (cached !== undefined) return cached @@ -5934,28 +6529,41 @@ class ExportService { const WRITE_BATCH = 100 let writeBuf: string[] = [] - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { if ((i & 0x7f) === 0) { this.throwIfStopRequested(control) } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) || null const isSenderMe = msg.isSend const senderInfo = collected.memberSet.get(msg.senderUsername)?.member const senderName = isGroup - ? (await this.resolveExportDisplayProfile( - isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), - isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] - )).displayName + ? (() => { + const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}` + const cached = senderProfileCache.get(senderKey) + if (cached) return cached.displayName + return '' + })() : (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId)) + const resolvedSenderName = isGroup && !senderName + ? (await (async () => { + const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}` + const profile = await this.resolveExportDisplayProfile( + isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), + isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderKey, profile) + return profile.displayName + })()) + : senderName - const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) + const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName) const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) @@ -5968,7 +6576,7 @@ class ExportService { msg.isSend ) if (msg.localType === 34 && useVoiceTranscript) { - textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { textContent = '' @@ -6013,7 +6621,7 @@ class ExportService { ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` : '') const senderNameHtml = isGroup - ? `
${this.escapeHtml(senderName)}
` + ? `
${this.escapeHtml(resolvedSenderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` @@ -6043,7 +6651,10 @@ class ExportService { current: 60 + Math.floor((i + 1) / sortedMessages.length * 30), total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -6168,7 +6779,11 @@ class ExportService { current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) resolve({ success: true }) }) @@ -6443,6 +7058,10 @@ class ExportService { let failCount = 0 const successSessionIds: string[] = [] const failedSessionIds: string[] = [] + const progressEmitter = this.createProgressEmitter(onProgress) + const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { + progressEmitter.emit(progress, options) + } try { const conn = await this.ensureConnected() @@ -6463,9 +7082,13 @@ class ExportService { const exportBaseDir = writeLayout === 'A' ? path.join(outputDir, 'texts') : outputDir - if (!fs.existsSync(exportBaseDir)) { - fs.mkdirSync(exportBaseDir, { recursive: true }) + const createdTaskDirs = new Set() + const ensureTaskDir = async (dirPath: string) => { + if (createdTaskDirs.has(dirPath)) return + await fs.promises.mkdir(dirPath, { recursive: true }) + createdTaskDirs.add(dirPath) } + await ensureTaskDir(exportBaseDir) const sessionLayout = exportMediaEnabled ? (effectiveOptions.sessionLayout ?? 'per-session') : 'shared' @@ -6521,7 +7144,7 @@ class ExportService { const EMPTY_SESSION_PRECHECK_LIMIT = 1200 if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) { let checkedCount = 0 - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: '', @@ -6558,7 +7181,7 @@ class ExportService { } checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length) - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: '', @@ -6570,7 +7193,7 @@ class ExportService { }) } } else { - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: '', @@ -6653,14 +7276,16 @@ class ExportService { successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', - phaseLabel: '该会话没有消息,已跳过' - }) + phaseLabel: '该会话没有消息,已跳过', + estimatedTotalMessages: 0, + exportedMessages: 0 + }, { force: true }) return 'done' } @@ -6669,14 +7294,16 @@ class ExportService { successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', - phaseLabel: '该会话没有消息,已跳过' - }) + phaseLabel: '该会话没有消息,已跳过', + estimatedTotalMessages: 0, + exportedMessages: 0 + }, { force: true }) return 'done' } @@ -6687,13 +7314,13 @@ class ExportService { ? 1 : Math.max(0, Math.min(1, phaseCurrent / phaseTotal)) activeSessionRatios.set(sessionId, ratio) - onProgress?.({ + emitProgress({ ...progress, current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId - }) + }, { force: progress.phase === 'complete' }) } sessionProgress({ @@ -6715,8 +7342,8 @@ class ExportService { const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir - if (useSessionFolder && !fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) + if (useSessionFolder) { + await ensureTaskDir(sessionDir) } let ext = '.json' @@ -6731,7 +7358,7 @@ class ExportService { messageCountHint >= 0 && typeof latestTimestampHint === 'number' && latestTimestampHint > 0 && - fs.existsSync(outputPath) + await this.pathExists(outputPath) if (canTrySkipUnchanged) { const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) const hasNoDataChange = Boolean( @@ -6744,14 +7371,16 @@ class ExportService { successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', - phaseLabel: '无变化,已跳过' - }) + phaseLabel: '无变化,已跳过', + estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)), + exportedMessages: Math.max(0, Math.floor(messageCountHint || 0)) + }, { force: true }) return 'done' } } @@ -6797,14 +7426,14 @@ class ExportService { activeSessionRatios.delete(sessionId) completedCount++ - onProgress?.({ + emitProgress({ current: computeAggregateCurrent(), total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, phase: 'complete', phaseLabel: result.success ? '完成' : '导出失败' - }) + }, { force: true }) return 'done' } catch (error) { if (this.isStopError(error)) { @@ -6886,16 +7515,18 @@ class ExportService { } } - onProgress?.({ + emitProgress({ current: sessionIds.length, total: sessionIds.length, currentSession: '', currentSessionId: '', phase: 'complete' - }) + }, { force: true }) + progressEmitter.flush() return { success: true, successCount, failCount, successSessionIds, failedSessionIds } } catch (e) { + progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } } } diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 7a03d37..8d66ce9 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -230,10 +230,9 @@ class GroupAnalyticsService { } try { - const escapedChatroomId = chatroomId.replace(/'/g, "''") - const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`) - if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) { - const owner = tryResolve(roomResult.rows[0]) + const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (roomExt.success && roomExt.extBuffer) { + const owner = tryResolve({ ext_buffer: roomExt.extBuffer }) if (owner) return owner } } catch { @@ -273,13 +272,12 @@ class GroupAnalyticsService { } try { - const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' - const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) - if (!result.success || !result.rows || result.rows.length === 0) { + const result = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (!result.success || !result.extBuffer) { return nicknameMap } - const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) + const extBuffer = this.decodeExtBuffer(result.extBuffer) if (!extBuffer) return nicknameMap this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) return nicknameMap @@ -583,19 +581,9 @@ class GroupAnalyticsService { const batch = candidates.slice(i, i + batchSize) if (batch.length === 0) continue - const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',') - const lightweightSql = ` - SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type - FROM contact - WHERE username IN (${inList}) - ` - let result = await wcdbService.execQuery('contact', null, lightweightSql) - if (!result.success || !result.rows) { - // 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失 - result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`) - } - if (!result.success || !result.rows) continue - appendContactsToLookup(result.rows as Record[]) + const result = await wcdbService.getContactsCompact(batch) + if (!result.success || !result.contacts) continue + appendContactsToLookup(result.contacts as Record[]) } return lookup } @@ -774,31 +762,111 @@ class GroupAnalyticsService { return '' } + private normalizeCursorTimestamp(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0 + const normalized = Math.floor(value) + return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized + } + + private extractRowSenderUsername(row: Record): string { + const candidates = [ + row.sender_username, + row.senderUsername, + row.sender, + row.WCDB_CT_sender_username + ] + for (const candidate of candidates) { + const value = String(candidate || '').trim() + if (value) return value + } + for (const [key, value] of Object.entries(row)) { + const normalizedKey = key.toLowerCase() + if ( + normalizedKey === 'sender_username' || + normalizedKey === 'senderusername' || + normalizedKey === 'sender' || + normalizedKey === 'wcdb_ct_sender_username' + ) { + const normalizedValue = String(value || '').trim() + if (normalizedValue) return normalizedValue + } + } + return '' + } + + private parseSingleMessageRow(row: Record): Message | null { + try { + const mapped = chatService.mapRowsToMessagesForApi([row]) + return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null + } catch { + return null + } + } + + private async openMemberMessageCursor( + chatroomId: string, + batchSize: number, + ascending: boolean, + startTime: number, + endTime: number + ): Promise<{ success: boolean; cursor?: number; error?: string }> { + const beginTimestamp = this.normalizeCursorTimestamp(startTime) + const endTimestamp = this.normalizeCursorTimestamp(endTime) + const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) + if (liteResult.success && liteResult.cursor) return liteResult + return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) + } + private async collectMessagesByMember( chatroomId: string, memberUsername: string, startTime: number, endTime: number ): Promise<{ success: boolean; data?: Message[]; error?: string }> { - const batchSize = 500 + const batchSize = 800 const matchedMessages: Message[] = [] - let offset = 0 + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(memberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } - while (true) { - const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true) - if (!batch.success || !batch.messages) { - return { success: false, error: batch.error || '获取群消息失败' } - } + const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建群消息游标失败' } + } - for (const message of batch.messages) { - if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) { - matchedMessages.push(message) + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取群消息失败' } } - } + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) break - const fetchedCount = batch.messages.length - if (fetchedCount <= 0 || !batch.hasMore) break - offset += fetchedCount + for (const row of rows) { + const senderFromRow = this.extractRowSenderUsername(row) + if (senderFromRow && !matchesTargetSender(senderFromRow)) { + continue + } + const message = this.parseSingleMessageRow(row) + if (!message) continue + if (matchesTargetSender(message.senderUsername)) { + matchedMessages.push(message) + } + } + + if (!batch.hasMore) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) } return { success: true, data: matchedMessages } @@ -832,57 +900,93 @@ class GroupAnalyticsService { : 0 const matchedMessages: Message[] = [] - const batchSize = Math.max(limit * 2, 100) + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } + const batchSize = Math.max(limit * 4, 240) let hasMore = false - while (matchedMessages.length < limit) { - const batch = await chatService.getMessages( - normalizedChatroomId, - cursor, - batchSize, - startTimeValue, - endTimeValue, - false - ) - if (!batch.success || !batch.messages) { - return { success: false, error: batch.error || '获取群成员消息失败' } - } + const cursorResult = await this.openMemberMessageCursor( + normalizedChatroomId, + batchSize, + false, + startTimeValue, + endTimeValue + ) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建群成员消息游标失败' } + } - const currentMessages = batch.messages - const nextCursor = typeof batch.nextOffset === 'number' - ? Math.max(cursor, Math.floor(batch.nextOffset)) - : cursor + currentMessages.length + let consumedRows = 0 + const dbCursor = cursorResult.cursor - let overflowMatchFound = false - for (const message of currentMessages) { - if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) { - continue + try { + while (matchedMessages.length < limit) { + const batch = await wcdbService.fetchMessageBatch(dbCursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取群成员消息失败' } } - if (matchedMessages.length < limit) { + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) { + hasMore = false + break + } + + let startIndex = 0 + if (cursor > consumedRows) { + const skipCount = Math.min(cursor - consumedRows, rows.length) + consumedRows += skipCount + startIndex = skipCount + if (startIndex >= rows.length) { + if (!batch.hasMore) { + hasMore = false + break + } + continue + } + } + + for (let index = startIndex; index < rows.length; index += 1) { + const row = rows[index] + consumedRows += 1 + + const senderFromRow = this.extractRowSenderUsername(row) + if (senderFromRow && !matchesTargetSender(senderFromRow)) { + continue + } + + const message = this.parseSingleMessageRow(row) + if (!message) continue + if (!matchesTargetSender(message.senderUsername)) { + continue + } + matchedMessages.push(message) - } else { - overflowMatchFound = true + if (matchedMessages.length >= limit) { + cursor = consumedRows + hasMore = index < rows.length - 1 || batch.hasMore === true + break + } + } + + if (matchedMessages.length >= limit) break + + cursor = consumedRows + if (!batch.hasMore) { + hasMore = false break } } - - cursor = nextCursor - - if (overflowMatchFound) { - hasMore = true - break - } - - if (currentMessages.length === 0 || !batch.hasMore) { - hasMore = false - break - } - - if (matchedMessages.length >= limit) { - hasMore = true - break - } + } finally { + await wcdbService.closeMessageCursor(dbCursor) } return { diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 64c0e0a..d1b5b21 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -55,14 +55,8 @@ type DecryptResult = { isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } -type HardlinkState = { - imageTable?: string - dirTable?: string -} - export class ImageDecryptService { private configService = new ConfigService() - private hardlinkCache = new Map() private resolvedCache = new Map() private pending = new Map>() private readonly defaultV1AesKey = 'cfcd208495d565ef' @@ -683,45 +677,19 @@ export class ImageDecryptService { private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { try { - const hardlinkPath = this.resolveHardlinkDbPath(accountDir) - if (!hardlinkPath) { - return null - } - const ready = await this.ensureWcdbReady() if (!ready) { this.logInfo('[ImageDecrypt] hardlink db not ready') return null } - const state = await this.getHardlinkState(accountDir, hardlinkPath) - if (!state.imageTable) { - this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath }) - return null - } + const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir) + if (!resolveResult.success || !resolveResult.data) return null + const fileName = String(resolveResult.data.file_name || '').trim() + const fullPath = String(resolveResult.data.full_path || '').trim() + if (!fileName) return null - const escapedMd5 = this.escapeSqlString(md5) - const rowResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1` - ) - const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null - - if (!row) { - this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) - return null - } - - const dir1 = this.getRowValue(row, 'dir1') - const dir2 = this.getRowValue(row, 'dir2') - const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') - if (dir1 === undefined || dir2 === undefined || !fileName) { - this.logInfo('[ImageDecrypt] hardlink row incomplete', { row }) - return null - } - - const lowerFileName = fileName.toLowerCase() + const lowerFileName = String(fileName).toLowerCase() if (lowerFileName.endsWith('.dat')) { const baseLower = lowerFileName.slice(0, -4) if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { @@ -730,57 +698,11 @@ export class ImageDecryptService { } } - // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 - let dir1Name: string | null = null - let dir2Name: string | null = null - - if (state.dirTable) { - try { - // 通过 rowid 查询目录名 - const dir1Result = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` - ) - if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) { - const value = this.getRowValue(dir1Result.rows[0], 'username') - if (value) dir1Name = String(value) - } - - const dir2Result = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1` - ) - if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) { - const value = this.getRowValue(dir2Result.rows[0], 'username') - if (value) dir2Name = String(value) - } - } catch { - // ignore - } + if (fullPath && existsSync(fullPath)) { + this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) + return fullPath } - - if (!dir1Name || !dir2Name) { - this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name }) - return null - } - - // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} - const possiblePaths = [ - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), - ] - - for (const fullPath of possiblePaths) { - if (existsSync(fullPath)) { - this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) - return fullPath - } - } - - this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) + this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 }) return null } catch { // ignore @@ -788,35 +710,6 @@ export class ImageDecryptService { return null } - private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise { - const cached = this.hardlinkCache.get(hardlinkPath) - if (cached) return cached - - const imageResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1" - ) - const dirResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1" - ) - const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0 - ? this.getRowValue(imageResult.rows[0], 'name') - : undefined - const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0 - ? this.getRowValue(dirResult.rows[0], 'name') - : undefined - const state: HardlinkState = { - imageTable: imageTable ? String(imageTable) : undefined, - dirTable: dirTable ? String(dirTable) : undefined - } - this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable }) - this.hardlinkCache.set(hardlinkPath, state) - return state - } - private async ensureWcdbReady(): Promise { if (wcdbService.isReady()) return true const dbPath = this.configService.get('dbPath') @@ -1992,7 +1885,6 @@ export class ImageDecryptService { async clearCache(): Promise<{ success: boolean; error?: string }> { this.resolvedCache.clear() - this.hardlinkCache.clear() this.pending.clear() this.updateFlags.clear() this.cacheIndexed = false diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index f87e8d0..af33b75 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -136,7 +136,7 @@ export class KeyServiceMac { if (sipStatus.enabled) { return { success: false, - error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑' + error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑' } } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 2bb2908..15497a9 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -663,100 +663,24 @@ class SnsService { } async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - const collect = (rows?: any[]): string[] => { - if (!Array.isArray(rows)) return [] - const usernames: string[] = [] - for (const row of rows) { - const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0] - const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim() - if (username) usernames.push(username) - } - return usernames + const result = await wcdbService.getSnsUsernames() + if (!result.success) { + return { success: false, error: result.error || '获取朋友圈联系人失败' } } - - const primary = await wcdbService.execQuery( - 'sns', - null, - "SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" - ) - const fallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - - const merged = Array.from(new Set([ - ...collect(primary.rows), - ...collect(fallback.rows) - ])) - - // 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。 - if (merged.length > 0) { - return { success: true, usernames: merged } - } - - // 两条查询都成功但无数据,说明确实没有朋友圈发布者。 - if (primary.success || fallback.success) { - return { success: true, usernames: [] } - } - - return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' } + return { success: true, usernames: result.usernames || [] } } private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { - let totalPosts = 0 - let totalFriends = 0 - let myPosts: number | null = null - - const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') - if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { - totalPosts = this.parseCountValue(postCountResult.rows[0]) - } - - if (totalPosts > 0) { - const friendCountPrimary = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" - ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) - } - } - } - const normalizedMyWxid = this.toOptionalString(myWxid) - if (normalizedMyWxid) { - const myPostPrimary = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?", - [normalizedMyWxid] - ) - if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) { - myPosts = this.parseCountValue(myPostPrimary.rows[0]) - } else { - const myPostFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?", - [normalizedMyWxid] - ) - if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) { - myPosts = this.parseCountValue(myPostFallback.rows[0]) - } - } + const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined) + if (!result.success || !result.data) { + return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null } + } + return { + totalPosts: Number(result.data.totalPosts || 0), + totalFriends: Number(result.data.totalFriends || 0), + myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0) } - - return { totalPosts, totalFriends, myPosts } } async getExportStats(options?: { diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 10eb1d2..09aad4d 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -70,7 +70,7 @@ class VideoService { /** * 从 video_hardlink_info_v4 表查询视频文件名 - * 使用 wcdbService.execQuery 查询加密的 hardlink.db + * 使用 wcdb 专属接口查询加密的 hardlink.db */ private async queryVideoFileName(md5: string): Promise { const dbPath = this.getDbPath() @@ -103,17 +103,11 @@ class VideoService { if (existsSync(p)) { try { this.log('尝试加密 hardlink.db', { path: p }) - const escapedMd5 = md5.replace(/'/g, "''") - const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - const result = await wcdbService.execQuery('media', p, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = result.rows[0] - if (row?.file_name) { - const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') - this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 }) - return realMd5 - } + const result = await wcdbService.resolveVideoHardlinkMd5(md5, p) + if (result.success && result.data?.resolved_md5) { + const realMd5 = String(result.data.resolved_md5) + this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 }) + return realMd5 } this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) } catch (e) { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index a54a460..eaf9d73 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -5,47 +5,6 @@ import { tmpdir } from 'os' // DLL 初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null -/** - * 解析 extra_buffer(protobuf)中的免打扰状态 - * - field 12 (tag 0x60): 值非0 = 免打扰 - * 折叠状态通过 contact.flag & 0x10000000 判断 - */ -function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } { - if (!raw) return { isMuted: false } - // execQuery 返回的 BLOB 列是十六进制字符串,需要先解码 - const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw - if (buf.length === 0) return { isMuted: false } - let isMuted = false - let i = 0 - const len = buf.length - - const readVarint = (): number => { - let result = 0, shift = 0 - while (i < len) { - const b = buf[i++] - result |= (b & 0x7f) << shift - shift += 7 - if (!(b & 0x80)) break - } - return result - } - - while (i < len) { - const tag = readVarint() - const fieldNum = tag >>> 3 - const wireType = tag & 0x07 - if (wireType === 0) { - const val = readVarint() - if (fieldNum === 12 && val !== 0) isMuted = true - } else if (wireType === 2) { - const sz = readVarint() - i += sz - } else if (wireType === 5) { i += 4 - } else if (wireType === 1) { i += 8 - } else { break } - } - return { isMuted } -} export function getLastDllInitError(): string | null { return lastDllInitError } @@ -86,6 +45,11 @@ export class WcdbCore { private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null private wcdbGetContactStatus: any = null + private wcdbGetContactTypeCounts: any = null + private wcdbGetContactsCompact: any = null + private wcdbGetContactAliasMap: any = null + private wcdbGetContactFriendFlags: any = null + private wcdbGetChatRoomExtBuffer: any = null private wcdbGetMessageTableStats: any = null private wcdbGetAggregateStats: any = null private wcdbGetAvailableYears: any = null @@ -106,9 +70,24 @@ export class WcdbCore { private wcdbGetEmoticonCdnUrl: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null + private wcdbGetVoiceDataBatch: any = null + private wcdbGetMediaSchemaSummary: any = null + private wcdbGetSessionMessageCounts: any = null + private wcdbGetSessionMessageTypeStats: any = null + private wcdbGetSessionMessageTypeStatsBatch: any = null + private wcdbGetSessionMessageDateCounts: any = null + private wcdbGetSessionMessageDateCountsBatch: any = null + private wcdbGetMessagesByType: any = null + private wcdbGetHeadImageBuffers: any = null private wcdbSearchMessages: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null + private wcdbGetSnsUsernames: any = null + private wcdbGetSnsExportStats: any = null + private wcdbGetMessageTableColumns: any = null + private wcdbGetMessageTableTimeRange: any = null + private wcdbResolveImageHardlink: any = null + private wcdbResolveVideoHardlinkMd5: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null @@ -719,6 +698,32 @@ export class WcdbCore { this.wcdbGetContactStatus = null } + try { + this.wcdbGetContactTypeCounts = this.lib.func('int32 wcdb_get_contact_type_counts(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetContactTypeCounts = null + } + try { + this.wcdbGetContactsCompact = this.lib.func('int32 wcdb_get_contacts_compact(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactsCompact = null + } + try { + this.wcdbGetContactAliasMap = this.lib.func('int32 wcdb_get_contact_alias_map(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactAliasMap = null + } + try { + this.wcdbGetContactFriendFlags = this.lib.func('int32 wcdb_get_contact_friend_flags(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactFriendFlags = null + } + try { + this.wcdbGetChatRoomExtBuffer = this.lib.func('int32 wcdb_get_chat_room_ext_buffer(int64 handle, const char* chatroomId, _Out_ void** outJson)') + } catch { + this.wcdbGetChatRoomExtBuffer = null + } + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -821,6 +826,51 @@ export class WcdbCore { } catch { this.wcdbGetVoiceData = null } + try { + this.wcdbGetVoiceDataBatch = this.lib.func('int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetVoiceDataBatch = null + } + try { + this.wcdbGetMediaSchemaSummary = this.lib.func('int32 wcdb_get_media_schema_summary(int64 handle, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbGetMediaSchemaSummary = null + } + try { + this.wcdbGetSessionMessageCounts = this.lib.func('int32 wcdb_get_session_message_counts(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageCounts = null + } + try { + this.wcdbGetSessionMessageTypeStats = this.lib.func('int32 wcdb_get_session_message_type_stats(int64 handle, const char* sessionId, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageTypeStats = null + } + try { + this.wcdbGetSessionMessageTypeStatsBatch = this.lib.func('int32 wcdb_get_session_message_type_stats_batch(int64 handle, const char* sessionIdsJson, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageTypeStatsBatch = null + } + try { + this.wcdbGetSessionMessageDateCounts = this.lib.func('int32 wcdb_get_session_message_date_counts(int64 handle, const char* sessionId, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageDateCounts = null + } + try { + this.wcdbGetSessionMessageDateCountsBatch = this.lib.func('int32 wcdb_get_session_message_date_counts_batch(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageDateCountsBatch = null + } + try { + this.wcdbGetMessagesByType = this.lib.func('int32 wcdb_get_messages_by_type(int64 handle, const char* sessionId, int64 localType, int32 ascending, int32 limit, int32 offset, _Out_ void** outJson)') + } catch { + this.wcdbGetMessagesByType = null + } + try { + this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetHeadImageBuffers = null + } // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) try { @@ -842,6 +892,36 @@ export class WcdbCore { } catch { this.wcdbGetSnsAnnualStats = null } + try { + this.wcdbGetSnsUsernames = this.lib.func('int32 wcdb_get_sns_usernames(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsUsernames = null + } + try { + this.wcdbGetSnsExportStats = this.lib.func('int32 wcdb_get_sns_export_stats(int64 handle, const char* myWxid, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsExportStats = null + } + try { + this.wcdbGetMessageTableColumns = this.lib.func('int32 wcdb_get_message_table_columns(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetMessageTableColumns = null + } + try { + this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetMessageTableTimeRange = null + } + try { + this.wcdbResolveImageHardlink = this.lib.func('int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)') + } catch { + this.wcdbResolveImageHardlink = null + } + try { + this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbResolveVideoHardlinkMd5 = null + } // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) try { @@ -1392,6 +1472,197 @@ export class WcdbCore { } } + async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageCounts) return this.getMessageCounts(sessionIds) + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话消息总数失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话消息总数失败' } + const raw = JSON.parse(jsonStr) || {} + const counts: Record = {} + for (const sid of sessionIds || []) { + const value = Number(raw?.[sid] ?? 0) + counts[sid] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageTypeStats( + sessionId: string, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageTypeStats) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageTypeStats( + this.handle, + sessionId, + this.normalizeTimestamp(beginTimestamp), + this.normalizeTimestamp(endTimestamp), + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话类型统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话类型统计失败' } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageTypeStatsBatch( + sessionIds: string[], + options?: { + beginTimestamp?: number + endTimestamp?: number + quickMode?: boolean + includeGroupSenderCount?: boolean + } + ): Promise<{ success: boolean; data?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return { success: true, data: {} } + + if (!this.wcdbGetSessionMessageTypeStatsBatch) { + const data: Record = {} + for (const sessionId of normalizedSessionIds) { + const single = await this.getSessionMessageTypeStats( + sessionId, + options?.beginTimestamp || 0, + options?.endTimestamp || 0 + ) + if (single.success) { + data[sessionId] = single.data || {} + } + } + return { success: true, data } + } + + try { + const outPtr = [null as any] + const optionsJson = JSON.stringify({ + begin: this.normalizeTimestamp(options?.beginTimestamp || 0), + end: this.normalizeTimestamp(options?.endTimestamp || 0), + quick_mode: options?.quickMode === true, + include_group_sender_count: options?.includeGroupSenderCount !== false + }) + const result = this.wcdbGetSessionMessageTypeStatsBatch( + this.handle, + JSON.stringify(normalizedSessionIds), + optionsJson, + outPtr + ) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话类型统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量会话类型统计失败' } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageDateCounts) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageDateCounts(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取会话日消息统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话日消息统计失败' } + const raw = JSON.parse(jsonStr) || {} + const counts: Record = {} + for (const [dateKey, value] of Object.entries(raw)) { + const count = Number(value) + if (!dateKey || !Number.isFinite(count) || count <= 0) continue + counts[String(dateKey)] = Math.floor(count) + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return { success: true, data: {} } + + if (!this.wcdbGetSessionMessageDateCountsBatch) { + const data: Record> = {} + for (const sessionId of normalizedSessionIds) { + const single = await this.getSessionMessageDateCounts(sessionId) + data[sessionId] = single.success && single.counts ? single.counts : {} + } + return { success: true, data } + } + + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageDateCountsBatch(this.handle, JSON.stringify(normalizedSessionIds), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话日消息统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量会话日消息统计失败' } + const raw = JSON.parse(jsonStr) || {} + const data: Record> = {} + for (const sessionId of normalizedSessionIds) { + const source = raw?.[sessionId] || {} + const next: Record = {} + for (const [dateKey, value] of Object.entries(source)) { + const count = Number(value) + if (!dateKey || !Number.isFinite(count) || count <= 0) continue + next[String(dateKey)] = Math.floor(count) + } + data[sessionId] = next + } + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessagesByType( + sessionId: string, + localType: number, + ascending = false, + limit = 0, + offset = 0 + ): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessagesByType) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessagesByType( + this.handle, + sessionId, + BigInt(localType), + ascending ? 1 : 0, + Math.max(0, Math.floor(limit || 0)), + Math.max(0, Math.floor(offset || 0)), + outPtr + ) + if (result !== 0 || !outPtr[0]) return { success: false, error: `按类型读取消息失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析按类型消息失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows: Array.isArray(rows) ? rows : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -1766,24 +2037,25 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetContactStatus) { + return { success: false, error: '接口未就绪' } + } try { - // 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL) - const BATCH = 200 + const outPtr = [null as any] + const code = this.wcdbGetContactStatus(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话状态失败: ${code}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话状态失败' } + + const rawMap = JSON.parse(jsonStr) || {} const map: Record = {} - for (let i = 0; i < usernames.length; i += BATCH) { - const batch = usernames.slice(i, i + BATCH) - const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})` - const result = await this.execQuery('contact', null, sql) - if (!result.success || !result.rows) continue - for (const row of result.rows) { - const uname: string = row.username - // 折叠:flag bit 28 (0x10000000) - const flag = parseInt(row.flag ?? '0', 10) - const isFolded = (flag & 0x10000000) !== 0 - // 免打扰:extra_buffer field 12 非0 - const { isMuted } = parseExtraBuffer(row.extra_buffer) - map[uname] = { isFolded, isMuted } + for (const username of usernames || []) { + const state = rawMap[username] || {} + map[username] = { + isFolded: Boolean(state.isFolded), + isMuted: Boolean(state.isMuted) } } return { success: true, map } @@ -1792,6 +2064,128 @@ export class WcdbCore { } } + async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageTableColumns) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableColumns(this.handle, dbPath, tableName, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表列失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表列失败' } + const columns = JSON.parse(jsonStr) + return { success: true, columns: Array.isArray(columns) ? columns.map((c: any) => String(c || '')) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableTimeRange(this.handle, dbPath, tableName, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表时间范围失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表时间范围失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactTypeCounts) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactTypeCounts(this.handle, outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人分类统计失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人分类统计失败' } + const raw = JSON.parse(jsonStr) || {} + return { + success: true, + counts: { + private: Number(raw.private || 0), + group: Number(raw.group || 0), + official: Number(raw.official || 0), + former_friend: Number(raw.former_friend || 0) + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactsCompact) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const payload = Array.isArray(usernames) && usernames.length > 0 ? JSON.stringify(usernames) : null + const code = this.wcdbGetContactsCompact(this.handle, payload, outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人列表失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人列表失败' } + const contacts = JSON.parse(jsonStr) + return { success: true, contacts: Array.isArray(contacts) ? contacts : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactAliasMap) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactAliasMap(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人 alias 失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人 alias 失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactFriendFlags) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactFriendFlags(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人好友标记失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人好友标记失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetChatRoomExtBuffer) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetChatRoomExtBuffer(this.handle, chatroomId, outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取群聊 ext_buffer 失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群聊 ext_buffer 失败' } + const data = JSON.parse(jsonStr) || {} + const extBuffer = String(data.ext_buffer || '').trim() + return { success: true, extBuffer: extBuffer || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -2078,8 +2472,11 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + const startedAt = Date.now() try { if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } + const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || '')) + this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`) // 如果提供了参数,使用参数化查询(需要 C++ 层支持) // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 @@ -2114,12 +2511,14 @@ export class WcdbCore { const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析查询结果失败' } const rows = JSON.parse(jsonStr) + this.writeLog(`[audit:execQuery] done kind=${kind} cost_ms=${Date.now() - startedAt} rows=${Array.isArray(rows) ? rows.length : -1}`) if (isContactQuery) { const count = Array.isArray(rows) ? rows.length : -1 this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) } return { success: true, rows } } catch (e) { + this.writeLog(`[audit:execQuery] fail kind=${kind} cost_ms=${Date.now() - startedAt} err=${String(e)}`) const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) if (isContactQuery) { this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true) @@ -2209,6 +2608,93 @@ export class WcdbCore { } } + async getVoiceDataBatch( + requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetVoiceDataBatch) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const payload = JSON.stringify(Array.isArray(requests) ? requests : []) + const result = this.wcdbGetVoiceDataBatch(this.handle, payload, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取语音数据失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量语音数据失败' } + const rows = JSON.parse(jsonStr) + const normalized = Array.isArray(rows) ? rows.map((row: any) => ({ + index: Number(row?.index ?? 0), + hex: row?.hex ? String(row.hex) : undefined + })) : [] + return { success: true, rows: normalized } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMediaSchemaSummary) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMediaSchemaSummary(this.handle, dbPath, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体表结构摘要失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析媒体表结构摘要失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetHeadImageBuffers) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetHeadImageBuffers(this.handle, JSON.stringify(usernames || []), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取头像二进制失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析头像二进制失败' } + const map = JSON.parse(jsonStr) || {} + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbResolveImageHardlink(this.handle, md5, accountDir || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbResolveVideoHardlinkMd5(this.handle, md5, dbPath || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 数据收集初始化 */ @@ -2373,6 +2859,45 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsUsernames) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSnsUsernames(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈用户名失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈用户名失败' } + const usernames = JSON.parse(jsonStr) + return { success: true, usernames: Array.isArray(usernames) ? usernames.map((u: any) => String(u || '').trim()).filter(Boolean) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsExportStats) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSnsExportStats(this.handle, myWxid || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈导出统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈导出统计失败' } + const raw = JSON.parse(jsonStr) || {} + return { + success: true, + data: { + totalPosts: Number(raw.total_posts || 0), + totalFriends: Number(raw.total_friends || 0), + myPosts: raw.my_posts === null || raw.my_posts === undefined ? null : Number(raw.my_posts || 0) + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } /** * 为朋友圈安装删除 */ diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index b5fcb24..98ca962 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -222,6 +222,48 @@ export class WcdbService { return this.callWorker('getMessageCounts', { sessionIds }) } + async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getSessionMessageCounts', { sessionIds }) + } + + async getSessionMessageTypeStats( + sessionId: string, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp }) + } + + async getSessionMessageTypeStatsBatch( + sessionIds: string[], + options?: { + beginTimestamp?: number + endTimestamp?: number + quickMode?: boolean + includeGroupSenderCount?: boolean + } + ): Promise<{ success: boolean; data?: Record; error?: string }> { + return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options }) + } + + async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getSessionMessageDateCounts', { sessionId }) + } + + async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { + return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds }) + } + + async getMessagesByType( + sessionId: string, + localType: number, + ascending = false, + limit = 0, + offset = 0 + ): Promise<{ success: boolean; rows?: any[]; error?: string }> { + return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset }) + } + /** * 获取联系人昵称 */ @@ -287,6 +329,14 @@ export class WcdbService { return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset }) } + async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { + return this.callWorker('getMessageTableColumns', { dbPath, tableName }) + } + + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMessageTableTimeRange', { dbPath, tableName }) + } + /** * 获取联系人详情 */ @@ -301,6 +351,26 @@ export class WcdbService { return this.callWorker('getContactStatus', { usernames }) } + async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { + return this.callWorker('getContactTypeCounts') + } + + async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { + return this.callWorker('getContactsCompact', { usernames }) + } + + async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactAliasMap', { usernames }) + } + + async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactFriendFlags', { usernames }) + } + + async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { + return this.callWorker('getChatRoomExtBuffer', { chatroomId }) + } + /** * 获取聚合统计数据 */ @@ -372,7 +442,7 @@ export class WcdbService { } /** - * 执行 SQL 查询(支持参数化查询) + * 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容) */ async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { return this.callWorker('execQuery', { kind, path, sql, params }) @@ -417,6 +487,28 @@ export class WcdbService { return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) } + async getVoiceDataBatch( + requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { + return this.callWorker('getVoiceDataBatch', { requests }) + } + + async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMediaSchemaSummary', { dbPath }) + } + + async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getHeadImageBuffers', { usernames }) + } + + async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('resolveImageHardlink', { md5, accountDir }) + } + + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath }) + } + /** * 获取朋友圈 */ @@ -431,6 +523,14 @@ export class WcdbService { return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) } + async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { + return this.callWorker('getSnsUsernames') + } + + async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + return this.callWorker('getSnsExportStats', { myWxid }) + } + /** * 安装朋友圈删除拦截 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 5d02904..55ab3af 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -59,6 +59,24 @@ if (parentPort) { case 'getMessageCounts': result = await core.getMessageCounts(payload.sessionIds) break + case 'getSessionMessageCounts': + result = await core.getSessionMessageCounts(payload.sessionIds) + break + case 'getSessionMessageTypeStats': + result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp) + break + case 'getSessionMessageTypeStatsBatch': + result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options) + break + case 'getSessionMessageDateCounts': + result = await core.getSessionMessageDateCounts(payload.sessionId) + break + case 'getSessionMessageDateCountsBatch': + result = await core.getSessionMessageDateCountsBatch(payload.sessionIds) + break + case 'getMessagesByType': + result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset) + break case 'getDisplayNames': result = await core.getDisplayNames(payload.usernames) break @@ -89,12 +107,33 @@ if (parentPort) { case 'getMessageMeta': result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset) break + case 'getMessageTableColumns': + result = await core.getMessageTableColumns(payload.dbPath, payload.tableName) + break + case 'getMessageTableTimeRange': + result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName) + break case 'getContact': result = await core.getContact(payload.username) break case 'getContactStatus': result = await core.getContactStatus(payload.usernames) break + case 'getContactTypeCounts': + result = await core.getContactTypeCounts() + break + case 'getContactsCompact': + result = await core.getContactsCompact(payload.usernames) + break + case 'getContactAliasMap': + result = await core.getContactAliasMap(payload.usernames) + break + case 'getContactFriendFlags': + result = await core.getContactFriendFlags(payload.usernames) + break + case 'getChatRoomExtBuffer': + result = await core.getChatRoomExtBuffer(payload.chatroomId) + break case 'getAggregateStats': result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) break @@ -149,12 +188,33 @@ if (parentPort) { console.error('[wcdbWorker] getVoiceData failed:', result.error) } break + case 'getVoiceDataBatch': + result = await core.getVoiceDataBatch(payload.requests) + break + case 'getMediaSchemaSummary': + result = await core.getMediaSchemaSummary(payload.dbPath) + break + case 'getHeadImageBuffers': + result = await core.getHeadImageBuffers(payload.usernames) + break + case 'resolveImageHardlink': + result = await core.resolveImageHardlink(payload.md5, payload.accountDir) + break + case 'resolveVideoHardlinkMd5': + result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath) + break case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break case 'getSnsAnnualStats': result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) break + case 'getSnsUsernames': + result = await core.getSnsUsernames() + break + case 'getSnsExportStats': + result = await core.getSnsExportStats(payload.myWxid) + break case 'installSnsBlockDeleteTrigger': result = await core.installSnsBlockDeleteTrigger() break diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 31aa4a2..9ed2538 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 1454e37..1ebcf00 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1443,7 +1443,7 @@ function ChatPage(props: ChatPageProps) { window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: false, allowStaleCache: true, cacheOnly: true } ) ]) @@ -1476,6 +1476,7 @@ function ChatPage(props: ChatPageProps) { } let refreshIncludeRelations = false + let shouldRefreshStatsInBackground = false if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined @@ -1493,11 +1494,49 @@ function ChatPage(props: ChatPageProps) { } }) } + shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale) + } else { + shouldRefreshStatsInBackground = true } finishBackgroundTask(taskId, 'completed', { detail: '聊天页会话详情统计完成', progressText: '已完成' }) + + if (shouldRefreshStatsInBackground) { + setIsRefreshingDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (freshMetric) { + applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false) + } else if (freshMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: freshMeta.updatedAt, + statsStale: freshMeta.stale + } + }) + } + } + } catch (error) { + console.error('聊天页后台刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingDetailStats(false) + } + } + })() + } } catch (e) { console.error('加载会话详情补充统计失败:', e) finishBackgroundTask(taskId, 'failed', { @@ -5778,12 +5817,13 @@ function ChatPage(props: ChatPageProps) { // 下载完成后,触发页面刷新让组件重新尝试转写 // 通过更新缓存触发组件重新检查 if (pendingVoiceTranscriptRequest) { - // 清除缓存中的请求标记,让组件可以重新尝试 - const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}` // 不直接调用转写,而是让组件自己重试 // 通过触发一个自定义事件来通知所有 MessageBubble 组件 window.dispatchEvent(new CustomEvent('model-downloaded', { - detail: { messageId: pendingVoiceTranscriptRequest.messageId } + detail: { + sessionId: pendingVoiceTranscriptRequest.sessionId, + messageId: pendingVoiceTranscriptRequest.messageId + } })) } setPendingVoiceTranscriptRequest(null) @@ -6298,6 +6338,20 @@ const voiceTranscriptCache = new Map() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() +const buildVoiceCacheIdentity = ( + sessionId: string, + message: Pick +): string => { + const normalizedSessionId = String(sessionId || '').trim() + const localId = Math.max(0, Math.floor(Number(message?.localId || 0))) + const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0))) + const serverIdRaw = String(message?.serverId ?? '').trim() + const serverId = /^\d+$/.test(serverIdRaw) + ? serverIdRaw.replace(/^0+(?=\d)/, '') + : String(Math.max(0, Math.floor(Number(serverIdRaw || 0)))) + return `${normalizedSessionId}:${localId}:${createTime}:${serverId || '0'}` +} + // 引用消息中的动画表情组件 function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { const cacheKey = md5 || cdnUrl @@ -6372,11 +6426,12 @@ function MessageBubble({ const [imageLocalPath, setImageLocalPath] = useState( () => imageDataUrlCache.get(imageCacheKey) ) - const voiceCacheKey = `voice:${message.localId}` + const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message) + const voiceCacheKey = `voice:${voiceIdentityKey}` const [voiceDataUrl, setVoiceDataUrl] = useState( () => voiceDataUrlCache.get(voiceCacheKey) ) - const voiceTranscriptCacheKey = `voice-transcript:${message.localId}` + const voiceTranscriptCacheKey = `voice-transcript:${voiceIdentityKey}` const [voiceTranscript, setVoiceTranscript] = useState( () => voiceTranscriptCache.get(voiceTranscriptCacheKey) ) @@ -6938,14 +6993,16 @@ function MessageBubble({ // 监听流式转写结果 useEffect(() => { if (!isVoice) return - const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => { - if (payload.msgId === String(message.localId)) { - setVoiceTranscript(payload.text) - voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) - } + const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => { + const sameSession = !payload.sessionId || payload.sessionId === session.username + const sameMsgId = payload.msgId === String(message.localId) + const sameCreateTime = payload.createTime == null || Number(payload.createTime) === Number(message.createTime || 0) + if (!sameSession || !sameMsgId || !sameCreateTime) return + setVoiceTranscript(payload.text) + voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) }) return () => removeListener?.() - }, [isVoice, message.localId, voiceTranscriptCacheKey]) + }, [isVoice, message.createTime, message.localId, session.username, voiceTranscriptCacheKey]) const requestVoiceTranscript = useCallback(async () => { if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return @@ -6999,14 +7056,17 @@ function MessageBubble({ } finally { setVoiceTranscriptLoading(false) } - }, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) + }, [message.createTime, message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) // 监听模型下载完成事件 useEffect(() => { if (!isVoice) return const handleModelDownloaded = (event: CustomEvent) => { - if (event.detail?.messageId === String(message.localId)) { + if ( + event.detail?.messageId === String(message.localId) && + (!event.detail?.sessionId || event.detail?.sessionId === session.username) + ) { // 重置状态,允许重新尝试转写 voiceTranscriptRequestedRef.current = false setVoiceTranscriptError(false) @@ -7019,7 +7079,7 @@ function MessageBubble({ return () => { window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener) } - }, [isVoice, message.localId, requestVoiceTranscript]) + }, [isVoice, message.localId, requestVoiceTranscript, session.username]) // 视频懒加载 const videoAutoLoadTriggered = useRef(false) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0b228ea..248b946 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' import { @@ -44,6 +44,7 @@ import { subscribeBackgroundTasks } from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import { useChatStore } from '../stores/chatStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' @@ -104,6 +105,10 @@ interface TaskProgress { phaseLabel: string phaseProgress: number phaseTotal: number + exportedMessages: number + estimatedTotalMessages: number + collectedMessages: number + writtenFiles: number } type TaskPerfStage = 'collect' | 'build' | 'write' | 'other' @@ -166,7 +171,7 @@ interface ExportDialogState { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 -const SESSION_MEDIA_METRIC_BATCH_SIZE = 12 +const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 @@ -254,7 +259,11 @@ const createEmptyProgress = (): TaskProgress => ({ phase: '', phaseLabel: '', phaseProgress: 0, - phaseTotal: 0 + phaseTotal: 0, + exportedMessages: 0, + estimatedTotalMessages: 0, + collectedMessages: 0, + writtenFiles: 0 }) const createEmptyTaskPerformance = (): TaskPerformance => ({ @@ -1280,6 +1289,14 @@ const TaskCenterModal = memo(function TaskCenterModal({ completedSessionTotal, (task.settledSessionIds || []).length ) + const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0)) + const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0)) + const messageProgressLabel = estimatedTotalMessages > 0 + ? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条` + : `已导出 ${exportedMessages} 条` + const sessionProgressLabel = completedSessionTotal > 0 + ? `会话 ${completedSessionCount}/${completedSessionTotal}` + : '会话处理中' const currentSessionRatio = task.progress.phaseTotal > 0 ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) : null @@ -1300,9 +1317,7 @@ const TaskCenterModal = memo(function TaskCenterModal({ />
- {completedSessionTotal > 0 - ? `已完成 ${completedSessionCount} / ${completedSessionTotal}` - : '处理中'} + {`${sessionProgressLabel} · ${messageProgressLabel}`} {task.status === 'running' && currentSessionRatio !== null ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` : ''} @@ -1387,6 +1402,8 @@ const TaskCenterModal = memo(function TaskCenterModal({ }) function ExportPage() { + const navigate = useNavigate() + const { setCurrentSession } = useChatStore() const location = useLocation() const isExportRoute = location.pathname === '/export' @@ -2787,6 +2804,7 @@ function ExportPage() { }, []) const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (activeTaskCountRef.current > 0) return const front = options?.front === true const incoming: string[] = [] for (const sessionIdRaw of sessionIds) { @@ -2976,6 +2994,7 @@ function ExportPage() { }, []) const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (activeTaskCountRef.current > 0) return const front = options?.front === true const incoming: string[] = [] for (const sessionIdRaw of sessionIds) { @@ -3025,13 +3044,27 @@ function ExportPage() { const runSessionMediaMetricWorker = useCallback(async (runId: number) => { if (sessionMediaMetricWorkerRunningRef.current) return sessionMediaMetricWorkerRunningRef.current = true + const withTimeout = async (promise: Promise, timeoutMs: number, stage: string): Promise => { + let timer: number | null = null + try { + const timeoutPromise = new Promise((_, reject) => { + timer = window.setTimeout(() => { + reject(new Error(`会话多媒体统计超时(${stage}, ${timeoutMs}ms)`)) + }, timeoutMs) + }) + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timer !== null) { + window.clearTimeout(timer) + } + } + } try { while (runId === sessionMediaMetricRunIdRef.current) { - if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) { - await new Promise(resolve => window.setTimeout(resolve, 80)) + if (activeTaskCountRef.current > 0) { + await new Promise(resolve => window.setTimeout(resolve, 150)) continue } - if (sessionMediaMetricQueueRef.current.length === 0) break const batchSessionIds: string[] = [] @@ -3050,9 +3083,13 @@ function ExportPage() { patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') try { - const cacheResult = await window.electronAPI.chat.getExportSessionStats( - batchSessionIds, - { includeRelations: false, allowStaleCache: true, cacheOnly: true } + const cacheResult = await withTimeout( + window.electronAPI.chat.getExportSessionStats( + batchSessionIds, + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ), + 12000, + 'cacheOnly' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (cacheResult.success && cacheResult.data) { @@ -3061,15 +3098,26 @@ function ExportPage() { const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) if (missingSessionIds.length > 0) { - const freshResult = await window.electronAPI.chat.getExportSessionStats( - missingSessionIds, - { includeRelations: false, allowStaleCache: true } + const freshResult = await withTimeout( + window.electronAPI.chat.getExportSessionStats( + missingSessionIds, + { includeRelations: false, allowStaleCache: true } + ), + 45000, + 'fresh' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (freshResult.success && freshResult.data) { applySessionMediaMetricsFromStats(freshResult.data as Record) } } + + const unresolvedSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) + if (unresolvedSessionIds.length > 0) { + patchSessionLoadTraceStage(unresolvedSessionIds, 'mediaMetrics', 'failed', { + error: '统计结果缺失,已跳过当前批次' + }) + } } catch (error) { console.error('导出页加载会话媒体统计失败:', error) patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { @@ -3100,12 +3148,11 @@ function ExportPage() { }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { - if (!isSessionCountStageReady) return - if (isLoadingSessionCountsRef.current) return + if (activeTaskCountRef.current > 0) return if (sessionMediaMetricWorkerRunningRef.current) return const runId = sessionMediaMetricRunIdRef.current void runSessionMediaMetricWorker(runId) - }, [isSessionCountStageReady, runSessionMediaMetricWorker]) + }, [runSessionMediaMetricWorker]) const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise => { const normalizedSessionId = String(sessionId || '').trim() @@ -3150,6 +3197,10 @@ function ExportPage() { sessionMutualFriendsWorkerRunningRef.current = true try { while (runId === sessionMutualFriendsRunIdRef.current) { + if (activeTaskCountRef.current > 0) { + await new Promise(resolve => window.setTimeout(resolve, 150)) + continue + } if (hasPendingMetricLoads()) { await new Promise(resolve => window.setTimeout(resolve, 120)) continue @@ -3196,6 +3247,7 @@ function ExportPage() { ]) const scheduleSessionMutualFriendsWorker = useCallback(() => { + if (activeTaskCountRef.current > 0) return if (!isSessionCountStageReady) return if (hasPendingMetricLoads()) return if (sessionMutualFriendsWorkerRunningRef.current) return @@ -3291,9 +3343,6 @@ function ExportPage() { setIsLoadingSessionCounts(true) try { - if (detailStatsPriorityRef.current) { - return { ...accumulatedCounts } - } if (prioritizedSessionIds.length > 0) { patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) @@ -3311,9 +3360,6 @@ function ExportPage() { } } - if (detailStatsPriorityRef.current) { - return { ...accumulatedCounts } - } if (remainingSessionIds.length > 0) { patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) @@ -4135,6 +4181,126 @@ function ExportPage() { progressUnsubscribeRef.current?.() const settledSessionIdsFromProgress = new Set() + const sessionMessageProgress = new Map() + let queuedProgressPayload: ExportProgress | null = null + let queuedProgressRaf: number | null = null + let queuedProgressTimer: number | null = null + + const clearQueuedProgress = () => { + if (queuedProgressRaf !== null) { + window.cancelAnimationFrame(queuedProgressRaf) + queuedProgressRaf = null + } + if (queuedProgressTimer !== null) { + window.clearTimeout(queuedProgressTimer) + queuedProgressTimer = null + } + } + + const updateSessionMessageProgress = (payload: ExportProgress) => { + const sessionId = String(payload.currentSessionId || '').trim() + if (!sessionId) return + const prev = sessionMessageProgress.get(sessionId) || { exported: 0, total: 0, knownTotal: false } + const nextExported = Number.isFinite(payload.exportedMessages) + ? Math.max(prev.exported, Math.max(0, Math.floor(Number(payload.exportedMessages || 0)))) + : prev.exported + const hasEstimatedTotal = Number.isFinite(payload.estimatedTotalMessages) + const nextTotal = hasEstimatedTotal + ? Math.max(prev.total, Math.max(0, Math.floor(Number(payload.estimatedTotalMessages || 0)))) + : prev.total + const knownTotal = prev.knownTotal || hasEstimatedTotal + sessionMessageProgress.set(sessionId, { + exported: nextExported, + total: nextTotal, + knownTotal + }) + } + + const resolveAggregatedMessageProgress = () => { + let exported = 0 + let estimated = 0 + let allKnown = true + for (const sessionId of next.payload.sessionIds) { + const entry = sessionMessageProgress.get(sessionId) + if (!entry) { + allKnown = false + continue + } + exported += entry.exported + estimated += entry.total + if (!entry.knownTotal) { + allKnown = false + } + } + return { + exported: Math.max(0, Math.floor(exported)), + estimated: allKnown ? Math.max(0, Math.floor(estimated)) : 0 + } + } + + const flushQueuedProgress = () => { + if (!queuedProgressPayload) return + const payload = queuedProgressPayload + queuedProgressPayload = null + const now = Date.now() + const currentSessionId = String(payload.currentSessionId || '').trim() + updateTask(next.id, task => { + if (task.status !== 'running') return task + const performance = applyProgressToTaskPerformance(task, payload, now) + const settledSessionIds = task.settledSessionIds || [] + const nextSettledSessionIds = ( + payload.phase === 'complete' && + currentSessionId && + !settledSessionIds.includes(currentSessionId) + ) + ? [...settledSessionIds, currentSessionId] + : settledSessionIds + const aggregatedMessageProgress = resolveAggregatedMessageProgress() + const collectedMessages = Number.isFinite(payload.collectedMessages) + ? Math.max(0, Math.floor(Number(payload.collectedMessages || 0))) + : task.progress.collectedMessages + const writtenFiles = Number.isFinite(payload.writtenFiles) + ? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0)))) + : task.progress.writtenFiles + return { + ...task, + progress: { + current: payload.current, + total: payload.total, + currentName: payload.currentSession, + phase: payload.phase, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0, + exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported), + estimatedTotalMessages: aggregatedMessageProgress.estimated > 0 + ? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated) + : (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0), + collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages), + writtenFiles + }, + settledSessionIds: nextSettledSessionIds, + performance + } + }) + } + + const queueProgressUpdate = (payload: ExportProgress) => { + queuedProgressPayload = payload + if (payload.phase === 'complete') { + clearQueuedProgress() + flushQueuedProgress() + return + } + if (queuedProgressRaf !== null || queuedProgressTimer !== null) return + queuedProgressRaf = window.requestAnimationFrame(() => { + queuedProgressRaf = null + queuedProgressTimer = window.setTimeout(() => { + queuedProgressTimer = null + flushQueuedProgress() + }, 100) + }) + } if (next.payload.scope === 'sns') { progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { updateTask(next.id, task => { @@ -4148,7 +4314,11 @@ function ExportPage() { phase: 'exporting', phaseLabel: payload.status || '', phaseProgress: payload.total > 0 ? payload.current : 0, - phaseTotal: payload.total || 0 + phaseTotal: payload.total || 0, + exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages, + estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages, + collectedMessages: task.progress.collectedMessages, + writtenFiles: task.progress.writtenFiles } } }) @@ -4157,6 +4327,7 @@ function ExportPage() { progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { const now = Date.now() const currentSessionId = String(payload.currentSessionId || '').trim() + updateSessionMessageProgress(payload) if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) { settledSessionIdsFromProgress.add(currentSessionId) const phaseLabel = String(payload.phaseLabel || '') @@ -4172,33 +4343,7 @@ function ExportPage() { markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now) } } - - updateTask(next.id, task => { - if (task.status !== 'running') return task - const performance = applyProgressToTaskPerformance(task, payload, now) - const settledSessionIds = task.settledSessionIds || [] - const nextSettledSessionIds = ( - payload.phase === 'complete' && - currentSessionId && - !settledSessionIds.includes(currentSessionId) - ) - ? [...settledSessionIds, currentSessionId] - : settledSessionIds - return { - ...task, - progress: { - current: payload.current, - total: payload.total, - currentName: payload.currentSession, - phase: payload.phase, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0 - }, - settledSessionIds: nextSettledSessionIds, - performance - } - }) + queueProgressUpdate(payload) }) } @@ -4310,6 +4455,8 @@ function ExportPage() { performance: finalizeTaskPerformance(task, doneAt) })) } finally { + clearQueuedProgress() + flushQueuedProgress() progressUnsubscribeRef.current?.() progressUnsubscribeRef.current = null runningTaskIdRef.current = null @@ -4715,10 +4862,22 @@ function ExportPage() { return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) }, []) - const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => { + const getLoadDetailStatusLabel = useCallback(( + loaded: number, + total: number, + hasStarted: boolean, + hasLoading: boolean, + failedCount: number + ): string => { if (total <= 0) return '待加载' - if (loaded >= total) return `已完成 ${total}` - if (hasStarted) return `加载中 ${loaded}/${total}` + const terminalCount = loaded + failedCount + if (terminalCount >= total) { + if (failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})` + return `已完成 ${total}` + } + if (hasLoading) return `加载中 ${loaded}/${total}` + if (hasStarted && failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})` + if (hasStarted) return `已完成 ${loaded}/${total}` return '待加载' }, []) @@ -4728,7 +4887,9 @@ function ExportPage() { ): SessionLoadStageSummary => { const total = sessionIds.length let loaded = 0 + let failedCount = 0 let hasStarted = false + let hasLoading = false let earliestStart: number | undefined let latestFinish: number | undefined let latestProgressAt: number | undefined @@ -4742,6 +4903,12 @@ function ExportPage() { : Math.max(latestProgressAt, stage.finishedAt) } } + if (stage?.status === 'failed') { + failedCount += 1 + } + if (stage?.status === 'loading') { + hasLoading = true + } if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { hasStarted = true } @@ -4759,9 +4926,9 @@ function ExportPage() { return { total, loaded, - statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), + statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount), startedAt: earliestStart, - finishedAt: loaded >= total ? latestFinish : undefined, + finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined, latestProgressAt } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) @@ -4907,7 +5074,6 @@ function ExportPage() { const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } - if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length === 0) return enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) @@ -4923,13 +5089,13 @@ function ExportPage() { enqueueSessionMediaMetricRequests, enqueueSessionMutualFriendsRequests, filteredContacts, - isSessionCountStageReady, scheduleSessionMediaMetricWorker, scheduleSessionMutualFriendsWorker ]) useEffect(() => { - if (!isSessionCountStageReady || filteredContacts.length === 0) return + if (activeTaskCount > 0) return + if (filteredContacts.length === 0) return const runId = sessionMediaMetricRunIdRef.current const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) if (visibleTargets.length > 0) { @@ -4946,7 +5112,6 @@ function ExportPage() { let cursor = 0 const feedNext = () => { if (runId !== sessionMediaMetricRunIdRef.current) return - if (isLoadingSessionCountsRef.current) return const batchIds: string[] = [] while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { const contact = filteredContacts[cursor] @@ -4976,15 +5141,61 @@ function ExportPage() { } } }, [ + activeTaskCount, collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, - isSessionCountStageReady, scheduleSessionMediaMetricWorker, sessionRowByUsername ]) useEffect(() => { + if (activeTaskCount > 0) return + const runId = sessionMediaMetricRunIdRef.current + const allTargets = [ + ...(loadDetailTargetsByTab.private || []), + ...(loadDetailTargetsByTab.group || []), + ...(loadDetailTargetsByTab.former_friend || []) + ] + if (allTargets.length === 0) return + + let timer: number | null = null + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMediaMetricRunIdRef.current) return + const batchIds: string[] = [] + while (cursor < allTargets.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const sessionId = allTargets[cursor] + cursor += 1 + if (!sessionId) continue + batchIds.push(sessionId) + } + if (batchIds.length > 0) { + enqueueSessionMediaMetricRequests(batchIds) + scheduleSessionMediaMetricWorker() + } + if (cursor < allTargets.length) { + timer = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (timer !== null) { + window.clearTimeout(timer) + } + } + }, [ + activeTaskCount, + enqueueSessionMediaMetricRequests, + loadDetailTargetsByTab.former_friend, + loadDetailTargetsByTab.group, + loadDetailTargetsByTab.private, + scheduleSessionMediaMetricWorker + ]) + + useEffect(() => { + if (activeTaskCount > 0) return if (!isSessionCountStageReady || filteredContacts.length === 0) return const runId = sessionMutualFriendsRunIdRef.current const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) @@ -5031,6 +5242,7 @@ function ExportPage() { } } }, [ + activeTaskCount, collectVisibleSessionMutualFriendsTargets, enqueueSessionMutualFriendsRequests, filteredContacts, @@ -5348,16 +5560,16 @@ function ExportPage() { const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS - const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) + const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) - if (shouldRunPreciseRefresh) { + if (shouldRunBackgroundRefresh) { setIsRefreshingSessionDetailStats(true) void (async () => { try { - // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。 const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: false, forceRefresh: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { @@ -6083,14 +6295,10 @@ function ExportPage() {