diff --git a/.gitignore b/.gitignore index 8601fb0..34a4ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -57,11 +57,12 @@ Thumbs.db wcdb/ xkey/ +server/ *info -概述.md chatlab-format.md *.bak AGENTS.md .claude/ .agents/ -resources/wx_send \ No newline at end of file +resources/wx_send +概述.md \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index 91c6b14..4ec00b7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,7 +3,7 @@ import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { Worker } from 'worker_threads' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' -import { readFile, writeFile, mkdir } from 'fs/promises' +import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' import { existsSync } from 'fs' import { ConfigService } from './services/config' import { dbPathService } from './services/dbPathService' @@ -21,6 +21,8 @@ import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' +import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' +import { cloudControlService } from './services/cloudControlService' import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' @@ -84,6 +86,7 @@ let agreementWindow: BrowserWindow | null = null let onboardingWindow: BrowserWindow | null = null // Splash 启动窗口 let splashWindow: BrowserWindow | null = null +const sessionChatWindows = new Map() const keyService = new KeyService() let mainWindowReady = false @@ -94,6 +97,98 @@ let isDownloadInProgress = false let downloadProgressHandler: ((progress: any) => void) | null = null let downloadedHandler: (() => void) | null = null +type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' +type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' + +interface AnnualReportYearsProgressPayload { + years?: number[] + done: boolean + error?: string + canceled?: boolean + strategy?: AnnualReportYearsLoadStrategy + phase?: AnnualReportYearsLoadPhase + statusText?: string + nativeElapsedMs?: number + scanElapsedMs?: number + totalElapsedMs?: number + switched?: boolean + nativeTimedOut?: boolean +} + +interface AnnualReportYearsTaskState { + cacheKey: string + canceled: boolean + done: boolean + snapshot: AnnualReportYearsProgressPayload + updatedAt: number +} + +const annualReportYearsLoadTasks = new Map() +const annualReportYearsTaskByCacheKey = new Map() +const annualReportYearsSnapshotCache = new Map() +const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000 + +const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => { + const years = Array.isArray(snapshot.years) ? [...snapshot.years] : [] + return { ...snapshot, years } +} + +const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => { + return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}` +} + +const pruneAnnualReportYearsSnapshotCache = (): void => { + const now = Date.now() + for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) { + if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) { + annualReportYearsSnapshotCache.delete(cacheKey) + } + } +} + +const persistAnnualReportYearsSnapshot = ( + cacheKey: string, + taskId: string, + snapshot: AnnualReportYearsProgressPayload +): void => { + annualReportYearsSnapshotCache.set(cacheKey, { + taskId, + snapshot: normalizeAnnualReportYearsSnapshot(snapshot), + updatedAt: Date.now() + }) + pruneAnnualReportYearsSnapshotCache() +} + +const getAnnualReportYearsSnapshot = ( + cacheKey: string +): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => { + pruneAnnualReportYearsSnapshotCache() + const entry = annualReportYearsSnapshotCache.get(cacheKey) + if (!entry) return null + return { + taskId: entry.taskId, + snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot) + } +} + +const broadcastAnnualReportYearsProgress = ( + taskId: string, + payload: AnnualReportYearsProgressPayload +): void => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue + win.webContents.send('annualReport:availableYearsProgress', { + taskId, + ...payload + }) + } +} + +const isYearsLoadCanceled = (taskId: string): boolean => { + const task = annualReportYearsLoadTasks.get(taskId) + return task?.canceled === true +} + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -590,6 +685,87 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { return win } +/** + * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) + */ +function createSessionChatWindow(sessionId: string) { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return null + + const existing = sessionChatWindows.get(normalizedSessionId) + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) { + existing.restore() + } + existing.focus() + return existing + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const isDark = nativeTheme.shouldUseDarkColors + + const win = new BrowserWindow({ + width: 600, + height: 820, + minWidth: 420, + minHeight: 560, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: isDark ? '#ffffff' : '#1a1a1a', + height: 40 + }, + show: false, + backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', + autoHideMenuBar: true + }) + + const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}` + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-window?${sessionParam}` + }) + } + + win.once('ready-to-show', () => { + win.show() + win.focus() + }) + + win.on('closed', () => { + const tracked = sessionChatWindows.get(normalizedSessionId) + if (tracked === win) { + sessionChatWindows.delete(normalizedSessionId) + } + }) + + sessionChatWindows.set(normalizedSessionId, win) + return win +} + function showMainWindow() { shouldShowMain = true if (mainWindowReady) { @@ -597,6 +773,65 @@ function showMainWindow() { } } +const normalizeAccountId = (value: string): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed +} + +const buildAccountNameMatcher = (wxidCandidates: string[]) => { + const loweredCandidates = wxidCandidates + .map((item) => String(item || '').trim().toLowerCase()) + .filter(Boolean) + return (name: string): boolean => { + const loweredName = String(name || '').trim().toLowerCase() + if (!loweredName) return false + return loweredCandidates.some((candidate) => ( + loweredName === candidate || + loweredName.startsWith(`${candidate}_`) || + loweredName.includes(candidate) + )) + } +} + +const removePathIfExists = async ( + targetPath: string, + removedPaths: string[], + warnings: string[] +): Promise => { + if (!targetPath || !existsSync(targetPath)) return + try { + await rm(targetPath, { recursive: true, force: true }) + removedPaths.push(targetPath) + } catch (error) { + warnings.push(`${targetPath}: ${String(error)}`) + } +} + +const removeMatchedEntriesInDir = async ( + rootDir: string, + shouldRemove: (name: string) => boolean, + removedPaths: string[], + warnings: string[] +): Promise => { + if (!rootDir || !existsSync(rootDir)) return + try { + const entries = await readdir(rootDir, { withFileTypes: true }) + for (const entry of entries) { + if (!shouldRemove(entry.name)) continue + const targetPath = join(rootDir, entry.name) + await removePathIfExists(targetPath, removedPaths, warnings) + } + } catch (error) { + warnings.push(`${rootDir}: ${String(error)}`) + } +} + // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() @@ -665,6 +900,39 @@ function registerIpcHandlers() { } }) + ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { + return exportCardDiagnosticsService.snapshot(options?.limit) + }) + + ipcMain.handle('diagnostics:clearExportCardLogs', async () => { + exportCardDiagnosticsService.clear() + return { success: true } + }) + + ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: { + filePath?: string + frontendLogs?: unknown[] + }) => { + const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : '' + if (!filePath) { + return { success: false, error: '导出路径不能为空' } + } + return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || []) + }) + + // 数据收集服务 + ipcMain.handle('cloud:init', async () => { + await cloudControlService.init() + }) + + ipcMain.handle('cloud:recordPage', (_, pageName: string) => { + cloudControlService.recordPage(pageName) + }) + + ipcMain.handle('cloud:getLogs', async () => { + return cloudControlService.getLogs() + }) + ipcMain.handle('app:checkForUpdates', async () => { if (!AUTO_UPDATE_ENABLED) { return { hasUpdate: false } @@ -802,6 +1070,12 @@ function registerIpcHandlers() { return true }) + // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) + ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => { + const win = createSessionChatWindow(sessionId) + return Boolean(win) + }) + // 根据视频尺寸调整窗口大小 ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -912,8 +1186,27 @@ function registerIpcHandlers() { return chatService.getSessions() }) - ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { - return chatService.enrichSessionsContactInfo(usernames) + ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => { + return chatService.getSessionStatuses(usernames) + }) + + ipcMain.handle('chat:getExportTabCounts', async () => { + return chatService.getExportTabCounts() + }) + + ipcMain.handle('chat:getContactTypeCounts', async () => { + return chatService.getContactTypeCounts() + }) + + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { + return chatService.getSessionMessageCounts(sessionIds) + }) + + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { + skipDisplayName?: boolean + onlyMissingAvatar?: boolean + }) => { + return chatService.enrichSessionsContactInfo(usernames, options) }) ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => { @@ -970,10 +1263,161 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('chat:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => { + const cfg = configService + if (!cfg) return { success: false, error: '配置服务未初始化' } + + const clearCache = options?.clearCache === true + const clearExports = options?.clearExports === true + if (!clearCache && !clearExports) { + return { success: false, error: '请至少选择一项清理范围' } + } + + const rawWxid = String(cfg.get('myWxid') || '').trim() + if (!rawWxid) { + return { success: false, error: '当前账号未登录或未识别,无法清理' } + } + const normalizedWxid = normalizeAccountId(rawWxid) + const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean))) + const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates) + const removedPaths: string[] = [] + const warnings: string[] = [] + + try { + wcdbService.close() + chatService.close() + } catch (error) { + warnings.push(`关闭数据库连接失败: ${String(error)}`) + } + + if (clearCache) { + const [analyticsResult, imageResult] = await Promise.all([ + analyticsService.clearCache(), + imageDecryptService.clearCache() + ]) + const chatResult = chatService.clearCaches() + const cleanupResults = [analyticsResult, imageResult, chatResult] + for (const result of cleanupResults) { + if (!result.success && result.error) warnings.push(result.error) + } + + const configuredCachePath = String(cfg.get('cachePath') || '').trim() + const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') + const userDataCacheDir = join(app.getPath('userData'), 'cache') + const cacheRootCandidates = [ + configuredCachePath, + join(documentsWeFlowDir, 'Images'), + join(documentsWeFlowDir, 'Voices'), + join(documentsWeFlowDir, 'Emojis'), + userDataCacheDir + ].filter(Boolean) + + for (const wxid of wxidCandidates) { + if (configuredCachePath) { + await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings) + } + await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings) + await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings) + await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings) + await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings) + } + + for (const cacheRoot of cacheRootCandidates) { + await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings) + } + } + + if (clearExports) { + const configuredExportPath = String(cfg.get('exportPath') || '').trim() + const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') + const exportRootCandidates = [ + configuredExportPath, + join(documentsWeFlowDir, 'exports'), + join(documentsWeFlowDir, 'Exports') + ].filter(Boolean) + + for (const exportRoot of exportRootCandidates) { + await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings) + } + + const resetConfigKeys = [ + 'exportSessionRecordMap', + 'exportLastSessionRunMap', + 'exportLastContentRunMap', + 'exportSessionMessageCountCacheMap', + 'exportSessionContentMetricCacheMap', + 'exportSnsStatsCacheMap', + 'snsPageCacheMap', + 'contactsListCacheMap', + 'contactsAvatarCacheMap', + 'lastSession' + ] + for (const key of resetConfigKeys) { + const defaultValue = key === 'lastSession' ? '' : {} + cfg.set(key as any, defaultValue as any) + } + } + + if (clearCache) { + try { + const wxidConfigsRaw = cfg.get('wxidConfigs') as Record | undefined + if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') { + const nextConfigs: Record = { ...wxidConfigsRaw } + for (const key of Object.keys(nextConfigs)) { + if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) { + delete nextConfigs[key] + } + } + cfg.set('wxidConfigs' as any, nextConfigs as any) + } + cfg.set('myWxid' as any, '') + cfg.set('decryptKey' as any, '') + cfg.set('imageXorKey' as any, 0) + cfg.set('imageAesKey' as any, '') + cfg.set('dbPath' as any, '') + cfg.set('lastOpenedDb' as any, '') + cfg.set('onboardingDone' as any, false) + cfg.set('lastSession' as any, '') + } catch (error) { + warnings.push(`清理账号配置失败: ${String(error)}`) + } + } + + return { + success: true, + removedPaths, + warning: warnings.length > 0 ? warnings.join('; ') : undefined + } + }) + ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => { + return chatService.getSessionDetailFast(sessionId) + }) + + ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => { + return chatService.getSessionDetailExtra(sessionId) + }) + + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + }) => { + return chatService.getExportSessionStats(sessionIds, options) + }) + + ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => { + return chatService.getGroupMyMessageCountHint(chatroomId) + }) + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { return chatService.getImageData(sessionId, msgId) }) @@ -990,6 +1434,9 @@ function registerIpcHandlers() { ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { return chatService.getMessageDates(sessionId) }) + ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => { + return chatService.getMessageDateCounts(sessionId) + }) ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { return chatService.resolveVoiceCache(sessionId, msgId) }) @@ -1016,6 +1463,14 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getExportStats', async () => { + return snsService.getExportStats() + }) + + ipcMain.handle('sns:getExportStatsFast', async () => { + return snsService.getExportStatsFast() + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) @@ -1063,11 +1518,17 @@ function registerIpcHandlers() { }) ipcMain.handle('sns:exportTimeline', async (event, options: any) => { - return snsService.exportTimeline(options, (progress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('sns:exportProgress', progress) + const exportOptions = { ...(options || {}) } + delete exportOptions.taskId + + return snsService.exportTimeline( + exportOptions, + (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('sns:exportProgress', progress) + } } - }) + ) }) ipcMain.handle('sns:selectExportDir', async () => { @@ -1196,6 +1657,7 @@ function registerIpcHandlers() { event.sender.send('export:progress', progress) } } + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) }) @@ -1285,6 +1747,16 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMembers(chatroomId) }) + ipcMain.handle( + 'groupAnalytics:getGroupMembersPanelData', + async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => { + const normalizedOptions = typeof options === 'boolean' + ? { forceRefresh: options } + : options + return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions) + } + ) + ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => { return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime) }) @@ -1365,6 +1837,194 @@ function registerIpcHandlers() { }) }) + ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => { + const cfg = configService || new ConfigService() + configService = cfg + + const dbPath = cfg.get('dbPath') + const decryptKey = cfg.get('decryptKey') + const wxid = cfg.get('myWxid') + const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid) + + const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey) + if (runningTaskId) { + const runningTask = annualReportYearsLoadTasks.get(runningTaskId) + if (runningTask && !runningTask.done) { + return { + success: true, + taskId: runningTaskId, + reused: true, + snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot) + } + } + annualReportYearsTaskByCacheKey.delete(cacheKey) + } + + const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey) + if (cachedSnapshot && cachedSnapshot.snapshot.done) { + return { + success: true, + taskId: cachedSnapshot.taskId, + reused: true, + snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot) + } + } + + const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done + ? { + ...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot), + done: false, + canceled: false, + error: undefined + } + : { + years: [], + done: false, + strategy: 'native', + phase: 'native', + statusText: '准备使用原生快速模式加载年份...', + nativeElapsedMs: 0, + scanElapsedMs: 0, + totalElapsedMs: 0, + switched: false, + nativeTimedOut: false + } + + const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => { + const task = annualReportYearsLoadTasks.get(taskId) + if (!task) return null + + const hasPayloadYears = Array.isArray(payload.years) + const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0)) + ? [...(payload.years || [])] + : Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : [] + + const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({ + ...task.snapshot, + ...payload, + years: nextYears + }) + task.snapshot = nextSnapshot + task.done = nextSnapshot.done === true + task.updatedAt = Date.now() + annualReportYearsLoadTasks.set(taskId, task) + persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot) + return nextSnapshot + } + + annualReportYearsLoadTasks.set(taskId, { + cacheKey, + canceled: false, + done: false, + snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot), + updatedAt: Date.now() + }) + annualReportYearsTaskByCacheKey.set(cacheKey, taskId) + persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot) + + void (async () => { + try { + const result = await annualReportService.getAvailableYears({ + dbPath, + decryptKey, + wxid, + nativeTimeoutMs: 5000, + onProgress: (progress) => { + if (isYearsLoadCanceled(taskId)) return + const snapshot = updateTaskSnapshot({ + ...progress, + done: false + }) + if (!snapshot) return + broadcastAnnualReportYearsProgress(taskId, snapshot) + }, + shouldCancel: () => isYearsLoadCanceled(taskId) + }) + + const canceled = isYearsLoadCanceled(taskId) + if (canceled) { + const snapshot = updateTaskSnapshot({ + done: true, + canceled: true, + phase: 'done', + statusText: '已取消年份加载' + }) + if (snapshot) { + broadcastAnnualReportYearsProgress(taskId, snapshot) + } + return + } + + const completionPayload: AnnualReportYearsProgressPayload = result.success + ? { + years: result.data || [], + done: true, + strategy: result.meta?.strategy, + phase: 'done', + statusText: result.meta?.statusText || '年份数据加载完成', + nativeElapsedMs: result.meta?.nativeElapsedMs, + scanElapsedMs: result.meta?.scanElapsedMs, + totalElapsedMs: result.meta?.totalElapsedMs, + switched: result.meta?.switched, + nativeTimedOut: result.meta?.nativeTimedOut + } + : { + years: result.data || [], + done: true, + error: result.error || '加载年度数据失败', + strategy: result.meta?.strategy, + phase: 'done', + statusText: result.meta?.statusText || '年份数据加载失败', + nativeElapsedMs: result.meta?.nativeElapsedMs, + scanElapsedMs: result.meta?.scanElapsedMs, + totalElapsedMs: result.meta?.totalElapsedMs, + switched: result.meta?.switched, + nativeTimedOut: result.meta?.nativeTimedOut + } + + const snapshot = updateTaskSnapshot(completionPayload) + if (snapshot) { + broadcastAnnualReportYearsProgress(taskId, snapshot) + } + } catch (e) { + const snapshot = updateTaskSnapshot({ + done: true, + error: String(e), + phase: 'done', + statusText: '年份数据加载失败', + strategy: 'hybrid' + }) + if (snapshot) { + broadcastAnnualReportYearsProgress(taskId, snapshot) + } + } finally { + const task = annualReportYearsLoadTasks.get(taskId) + if (task) { + annualReportYearsTaskByCacheKey.delete(task.cacheKey) + } + annualReportYearsLoadTasks.delete(taskId) + } + })() + + return { + success: true, + taskId, + reused: false, + snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot) + } + }) + + ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => { + const key = String(taskId || '').trim() + if (!key) return { success: false, error: '任务ID不能为空' } + const task = annualReportYearsLoadTasks.get(key) + if (!task) return { success: true } + task.canceled = true + annualReportYearsLoadTasks.set(key, task) + return { success: true } + }) + ipcMain.handle('annualReport:generateReport', async (_, year: number) => { const cfg = configService || new ConfigService() configService = cfg @@ -1528,7 +2188,7 @@ function registerIpcHandlers() { // 密钥获取 ipcMain.handle('key:autoGetDbKey', async (event) => { - return keyService.autoGetDbKey(60_000, (message, level) => { + return keyService.autoGetDbKey(180_000, (message, level) => { event.sender.send('key:dbKeyStatus', { message, level }) }) }) @@ -1539,6 +2199,12 @@ function registerIpcHandlers() { }, wxid) }) + ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => { + return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => { + event.sender.send('key:imageKeyStatus', { message }) + }) + }) + // HTTP API 服务 ipcMain.handle('http:start', async (_, port?: number) => { return httpService.start(port || 5031) diff --git a/electron/preload.ts b/electron/preload.ts index e81a267..c173d10 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -73,6 +73,15 @@ contextBridge.exposeInMainWorld('electronAPI', { debug: (data: any) => ipcRenderer.send('log:debug', data) }, + diagnostics: { + getExportCardLogs: (options?: { limit?: number }) => + ipcRenderer.invoke('diagnostics:getExportCardLogs', options), + clearExportCardLogs: () => + ipcRenderer.invoke('diagnostics:clearExportCardLogs'), + exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) => + ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload) + }, + // 窗口控制 window: { minimize: () => ipcRenderer.send('window:minimize'), @@ -89,7 +98,9 @@ contextBridge.exposeInMainWorld('electronAPI', { openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => - ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) + ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), + openSessionChatWindow: (sessionId: string) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId) }, // 数据库路径 @@ -114,6 +125,7 @@ contextBridge.exposeInMainWorld('electronAPI', { key: { autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid), + scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir), onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => { ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('key:dbKeyStatus') @@ -129,8 +141,14 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), - enrichSessionsContactInfo: (usernames: string[]) => - ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), + getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), + getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), + getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), + enrichSessionsContactInfo: ( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), getLatestMessages: (sessionId: string, limit?: number) => @@ -148,14 +166,25 @@ contextBridge.exposeInMainWorld('electronAPI', { getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), + clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => + ipcRenderer.invoke('chat:clearCurrentAccountData', options), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), + getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), + getExportSessionStats: ( + sessionIds: string[], + options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), + getGroupMyMessageCountHint: (chatroomId: string) => + ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), + 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) => { @@ -226,6 +255,10 @@ contextBridge.exposeInMainWorld('electronAPI', { groupAnalytics: { getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'), getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), + getGroupMembersPanelData: ( + chatroomId: string, + options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } + ) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), @@ -237,9 +270,29 @@ contextBridge.exposeInMainWorld('electronAPI', { // 年度报告 annualReport: { getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'), + startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'), + cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId), generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => ipcRenderer.invoke('annualReport:exportImages', payload), + onAvailableYearsProgress: (callback: (payload: { + taskId: string + years?: number[] + done: boolean + error?: string + canceled?: boolean + strategy?: 'cache' | 'native' | 'hybrid' + phase?: 'cache' | 'native' | 'scan' | 'done' + statusText?: string + nativeElapsedMs?: number + scanElapsedMs?: number + totalElapsedMs?: number + switched?: boolean + nativeTimedOut?: boolean + }) => void) => { + ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress') + }, onProgress: (callback: (payload: { status: string; progress: number }) => void) => { ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('annualReport:progress') @@ -264,7 +317,7 @@ 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; phase: string }) => void) => { + onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') } @@ -286,6 +339,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), + getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), @@ -302,6 +357,14 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) }, + + // 数据收集 + cloud: { + init: () => ipcRenderer.invoke('cloud:init'), + recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName), + getLogs: () => ipcRenderer.invoke('cloud:getLogs') + }, + // HTTP API 服务 http: { start: (port?: number) => ipcRenderer.invoke('http:start', port), diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 86a7086..f91cfc6 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -85,7 +85,34 @@ export interface AnnualReportData { } | null } +export interface AvailableYearsLoadProgress { + years: number[] + strategy: 'cache' | 'native' | 'hybrid' + phase: 'cache' | 'native' | 'scan' + statusText: string + nativeElapsedMs: number + scanElapsedMs: number + totalElapsedMs: number + switched?: boolean + nativeTimedOut?: boolean +} + +interface AvailableYearsLoadMeta { + strategy: 'cache' | 'native' | 'hybrid' + nativeElapsedMs: number + scanElapsedMs: number + totalElapsedMs: number + switched: boolean + nativeTimedOut: boolean + statusText: string +} + class AnnualReportService { + private readonly availableYearsCacheTtlMs = 10 * 60 * 1000 + private readonly availableYearsScanConcurrency = 4 + private readonly availableYearsColumnCache = new Map() + private readonly availableYearsCache = new Map() + constructor() { } @@ -181,6 +208,234 @@ class AnnualReportService { } } + private quoteSqlIdentifier(identifier: string): string { + return `"${String(identifier || '').replace(/"/g, '""')}"` + } + + private toUnixTimestamp(value: any): number { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return 0 + // 兼容毫秒级时间戳 + const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) + return seconds > 0 ? seconds : 0 + } + + private addYearsFromRange(years: Set, firstTs: number, lastTs: number): boolean { + let changed = false + const currentYear = new Date().getFullYear() + const minTs = firstTs > 0 ? firstTs : lastTs + const maxTs = lastTs > 0 ? lastTs : firstTs + if (minTs <= 0 || maxTs <= 0) return changed + + const minYear = new Date(minTs * 1000).getFullYear() + const maxYear = new Date(maxTs * 1000).getFullYear() + for (let y = minYear; y <= maxYear; y++) { + if (y >= 2010 && y <= currentYear && !years.has(y)) { + years.add(y) + changed = true + } + } + return changed + } + + private normalizeAvailableYears(years: Iterable): number[] { + return Array.from(new Set(Array.from(years))) + .filter((y) => Number.isFinite(y)) + .map((y) => Math.floor(y)) + .sort((a, b) => b - a) + } + + private async forEachWithConcurrency( + items: T[], + concurrency: number, + handler: (item: T, index: number) => Promise, + shouldStop?: () => boolean + ): Promise { + if (!items.length) return + const workerCount = Math.max(1, Math.min(concurrency, items.length)) + let nextIndex = 0 + const workers: Promise[] = [] + + for (let i = 0; i < workerCount; i++) { + workers.push((async () => { + while (true) { + if (shouldStop?.()) break + const current = nextIndex + nextIndex += 1 + if (current >= items.length) break + await handler(items[current], current) + } + })()) + } + + await Promise.all(workers) + } + + private async detectTimeColumn(dbPath: string, tableName: string): Promise { + const cacheKey = `${dbPath}\u0001${tableName}` + if (this.availableYearsColumnCache.has(cacheKey)) { + const cached = this.availableYearsColumnCache.get(cacheKey) || '' + 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) { + 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() + if (name) columns.add(name) + } + + for (const candidate of candidates) { + if (columns.has(candidate)) { + this.availableYearsColumnCache.set(cacheKey, candidate) + return candidate + } + } + + this.availableYearsColumnCache.set(cacheKey, '') + return null + } + + private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> { + const cacheKey = `${dbPath}\u0001${tableName}` + const cachedColumn = this.availableYearsColumnCache.get(cacheKey) + const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time' + 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 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 } + } + + tried.add(initialColumn) + const quick = await queryByColumn(initialColumn) + if (quick) { + if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn) + return quick + } + + const detectedColumn = await this.detectTimeColumn(dbPath, tableName) + if (!detectedColumn || tried.has(detectedColumn)) { + return null + } + + return queryByColumn(detectedColumn) + } + + private async getAvailableYearsByTableScan( + sessionIds: string[], + options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } + ): Promise { + const years = new Set() + let lastEmittedSize = 0 + + const emitIfChanged = (force = false) => { + if (!options?.onProgress) return + const next = this.normalizeAvailableYears(years) + if (!force && next.length === lastEmittedSize) return + options.onProgress(next) + lastEmittedSize = next.length + } + + const shouldCancel = () => options?.shouldCancel?.() === true + + await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => { + if (shouldCancel()) return + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) { + return + } + + for (const table of tableStats.tables as Record[]) { + if (shouldCancel()) return + const tableName = String(table.table_name || table.name || '').trim() + const dbPath = String(table.db_path || table.dbPath || '').trim() + if (!tableName || !dbPath) continue + + const range = await this.getTableTimeRange(dbPath, tableName) + if (!range) continue + const changed = this.addYearsFromRange(years, range.first, range.last) + if (changed) emitIfChanged() + } + }, shouldCancel) + + emitIfChanged(true) + return this.normalizeAvailableYears(years) + } + + private async getAvailableYearsByEdgeScan( + sessionIds: string[], + options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } + ): Promise { + const years = new Set() + let lastEmittedSize = 0 + const shouldCancel = () => options?.shouldCancel?.() === true + + const emitIfChanged = (force = false) => { + if (!options?.onProgress) return + const next = this.normalizeAvailableYears(years) + if (!force && next.length === lastEmittedSize) return + options.onProgress(next) + lastEmittedSize = next.length + } + + for (const sessionId of sessionIds) { + if (shouldCancel()) break + const first = await this.getEdgeMessageTime(sessionId, true) + const last = await this.getEdgeMessageTime(sessionId, false) + const changed = this.addYearsFromRange(years, first || 0, last || 0) + if (changed) emitIfChanged() + } + emitIfChanged(true) + return this.normalizeAvailableYears(years) + } + + private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string { + return `${dbPath}\u0001${cleanedWxid}` + } + + private getCachedAvailableYears(cacheKey: string): number[] | null { + const cached = this.availableYearsCache.get(cacheKey) + if (!cached) return null + if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) { + this.availableYearsCache.delete(cacheKey) + return null + } + return [...cached.years] + } + + private setCachedAvailableYears(cacheKey: string, years: number[]): void { + const normalized = this.normalizeAvailableYears(years) + + this.availableYearsCache.set(cacheKey, { + years: normalized, + updatedAt: Date.now() + }) + + if (this.availableYearsCache.size > 8) { + let oldestKey = '' + let oldestTime = Number.POSITIVE_INFINITY + for (const [key, val] of this.availableYearsCache) { + if (val.updatedAt < oldestTime) { + oldestTime = val.updatedAt + oldestKey = key + } + } + if (oldestKey) this.availableYearsCache.delete(oldestKey) + } + } + private decodeMessageContent(messageContent: any, compressContent: any): string { let content = this.decodeMaybeCompressed(compressContent) if (!content || content.length === 0) { @@ -359,38 +614,226 @@ class AnnualReportService { return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd } } - async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> { + async getAvailableYears(params: { + dbPath: string + decryptKey: string + wxid: string + onProgress?: (payload: AvailableYearsLoadProgress) => void + shouldCancel?: () => boolean + nativeTimeoutMs?: number + }): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> { try { + const isCancelled = () => params.shouldCancel?.() === true + const totalStartedAt = Date.now() + let nativeElapsedMs = 0 + let scanElapsedMs = 0 + let switched = false + let nativeTimedOut = false + let latestYears: number[] = [] + + const emitProgress = (payload: { + years?: number[] + strategy: 'cache' | 'native' | 'hybrid' + phase: 'cache' | 'native' | 'scan' + statusText: string + switched?: boolean + nativeTimedOut?: boolean + }) => { + if (!params.onProgress) return + if (Array.isArray(payload.years)) latestYears = payload.years + params.onProgress({ + years: latestYears, + strategy: payload.strategy, + phase: payload.phase, + statusText: payload.statusText, + nativeElapsedMs, + scanElapsedMs, + totalElapsedMs: Date.now() - totalStartedAt, + switched: payload.switched ?? switched, + nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut + }) + } + + const buildMeta = ( + strategy: 'cache' | 'native' | 'hybrid', + statusText: string + ): AvailableYearsLoadMeta => ({ + strategy, + nativeElapsedMs, + scanElapsedMs, + totalElapsedMs: Date.now() - totalStartedAt, + switched, + nativeTimedOut, + statusText + }) + const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) - if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } - - const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) - if (sessionIds.length === 0) { - return { success: false, error: '未找到消息会话' } - } - - const fastYears = await wcdbService.getAvailableYears(sessionIds) - if (fastYears.success && fastYears.data) { - return { success: true, data: fastYears.data } - } - - const years = new Set() - for (const sessionId of sessionIds) { - const first = await this.getEdgeMessageTime(sessionId, true) - const last = await this.getEdgeMessageTime(sessionId, false) - if (!first && !last) continue - - const minYear = new Date((first || last || 0) * 1000).getFullYear() - const maxYear = new Date((last || first || 0) * 1000).getFullYear() - for (let y = minYear; y <= maxYear; y++) { - if (y >= 2010 && y <= new Date().getFullYear()) years.add(y) + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') } + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid) + const cached = this.getCachedAvailableYears(cacheKey) + if (cached) { + latestYears = cached + emitProgress({ + years: cached, + strategy: 'cache', + phase: 'cache', + statusText: '命中缓存,已快速加载年份数据' + }) + return { + success: true, + data: cached, + meta: buildMeta('cache', '命中缓存,已快速加载年份数据') } } - const sortedYears = Array.from(years).sort((a, b) => b - a) - return { success: true, data: sortedYears } + const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionIds.length === 0) { + return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') } + } + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + + const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000)) + const nativeStartedAt = Date.now() + let nativeTicker: ReturnType | null = null + + emitProgress({ + strategy: 'native', + phase: 'native', + statusText: '正在使用原生快速模式加载年份...' + }) + nativeTicker = setInterval(() => { + nativeElapsedMs = Date.now() - nativeStartedAt + emitProgress({ + strategy: 'native', + phase: 'native', + statusText: '正在使用原生快速模式加载年份...' + }) + }, 120) + + const nativeRace = await Promise.race([ + wcdbService.getAvailableYears(sessionIds) + .then((result) => ({ kind: 'result' as const, result })) + .catch((error) => ({ kind: 'error' as const, error: String(error) })), + new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs)) + ]) + + if (nativeTicker) { + clearInterval(nativeTicker) + nativeTicker = null + } + nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt) + + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + + if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) { + const years = this.normalizeAvailableYears(nativeRace.result.data) + latestYears = years + this.setCachedAvailableYears(cacheKey, years) + emitProgress({ + years, + strategy: 'native', + phase: 'native', + statusText: '原生快速模式加载完成' + }) + return { + success: true, + data: years, + meta: buildMeta('native', '原生快速模式加载完成') + } + } + + switched = true + nativeTimedOut = nativeRace.kind === 'timeout' + emitProgress({ + strategy: 'hybrid', + phase: 'native', + statusText: nativeTimedOut + ? '原生快速模式超时,已自动切换到扫表兼容模式...' + : '原生快速模式不可用,已自动切换到扫表兼容模式...', + switched: true, + nativeTimedOut + }) + + const scanStartedAt = Date.now() + let scanTicker: ReturnType | null = null + scanTicker = setInterval(() => { + scanElapsedMs = Date.now() - scanStartedAt + emitProgress({ + strategy: 'hybrid', + phase: 'scan', + statusText: nativeTimedOut + ? '原生已超时,正在使用扫表兼容模式加载年份...' + : '正在使用扫表兼容模式加载年份...', + switched: true, + nativeTimedOut + }) + }, 120) + + let years = await this.getAvailableYearsByTableScan(sessionIds, { + onProgress: (items) => { + latestYears = items + scanElapsedMs = Date.now() - scanStartedAt + emitProgress({ + years: items, + strategy: 'hybrid', + phase: 'scan', + statusText: nativeTimedOut + ? '原生已超时,正在使用扫表兼容模式加载年份...' + : '正在使用扫表兼容模式加载年份...', + switched: true, + nativeTimedOut + }) + }, + shouldCancel: params.shouldCancel + }) + + if (isCancelled()) { + if (scanTicker) clearInterval(scanTicker) + return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + } + if (years.length === 0) { + years = await this.getAvailableYearsByEdgeScan(sessionIds, { + onProgress: (items) => { + latestYears = items + scanElapsedMs = Date.now() - scanStartedAt + emitProgress({ + years: items, + strategy: 'hybrid', + phase: 'scan', + statusText: '扫表结果为空,正在执行游标兜底扫描...', + switched: true, + nativeTimedOut + }) + }, + shouldCancel: params.shouldCancel + }) + } + if (scanTicker) { + clearInterval(scanTicker) + scanTicker = null + } + scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt) + + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + + this.setCachedAvailableYears(cacheKey, years) + latestYears = years + emitProgress({ + years, + strategy: 'hybrid', + phase: 'scan', + statusText: '扫表兼容模式加载完成', + switched: true, + nativeTimedOut + }) + return { + success: true, + data: years, + meta: buildMeta('hybrid', '扫表兼容模式加载完成') + } } catch (e) { - return { success: false, error: String(e) } + return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } } } } diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e188de8..e50817e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -12,6 +12,9 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' +import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' +import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService' +import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { voiceTranscribeService } from './voiceTranscribeService' import { LRUCache } from '../utils/LRUCache.js' @@ -29,6 +32,7 @@ export interface ChatSession { sortTimestamp: number // 用于排序 lastTimestamp: number // 用于显示时间 lastMsgType: number + messageCountHint?: number displayName?: string avatarUrl?: string lastMsgSender?: string @@ -132,13 +136,72 @@ export interface ContactInfo { displayName: string remark?: string nickname?: string + alias?: string avatarUrl?: string type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ExportSessionStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + transferMessages: number + redPacketMessages: number + callMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +interface ExportSessionStatsOptions { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean +} + +interface ExportSessionStatsCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + +interface ExportTabCounts { + private: number + group: number + official: number + former_friend: number +} + +interface SessionDetailFast { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number +} + +interface SessionDetailExtra { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +type SessionDetail = SessionDetailFast & SessionDetailExtra + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() +const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) class ChatService { private configService: ConfigService @@ -152,6 +215,8 @@ class ChatService { private hardlinkCache = new Map() private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService + private readonly sessionStatsCacheService: SessionStatsCacheService + private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService private voiceWavCache: LRUCache private voiceTranscriptCache: LRUCache private voiceTranscriptPending = new Map>() @@ -172,7 +237,35 @@ class ChatService { }>() // 缓存会话表信息,避免每次查询 private sessionTablesCache = new Map>() + private messageTableColumnsCache = new Map; updatedAt: number }>() private readonly sessionTablesCacheTtl = 300000 // 5分钟 + private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 + private sessionMessageCountCache = new Map() + private sessionMessageCountHintCache = new Map() + private sessionMessageCountBatchCache: { + dbSignature: string + sessionIdsKey: string + counts: Record + updatedAt: number + } | null = null + private sessionMessageCountCacheScope = '' + private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 + private readonly sessionMessageCountBatchCacheTtlMs = 5 * 60 * 1000 + private sessionDetailFastCache = new Map() + private sessionDetailExtraCache = new Map() + private readonly sessionDetailFastCacheTtlMs = 60 * 1000 + private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 + private sessionStatusCache = new Map() + private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 + private sessionStatsCacheScope = '' + private sessionStatsMemoryCache = new Map() + private sessionStatsPendingBasic = new Map>() + private sessionStatsPendingFull = new Map>() + private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null + private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 + private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 + private groupMyMessageCountCacheScope = '' + private groupMyMessageCountMemoryCache = new Map() constructor() { this.configService = new ConfigService() @@ -180,6 +273,8 @@ class ChatService { const persisted = this.contactCacheService.getAllEntries() this.avatarCache = new Map(Object.entries(persisted)) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) + this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) + this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath()) // 初始化LRU缓存,限制大小防止内存泄漏 this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 @@ -204,6 +299,18 @@ class ChatService { return cleaned } + /** + * 判断头像 URL 是否可用,过滤历史缓存里的错误 hex 数据。 + */ + private isValidAvatarUrl(avatarUrl?: string): avatarUrl is string { + const normalized = String(avatarUrl || '').trim() + if (!normalized) return false + const normalizedLower = normalized.toLowerCase() + if (normalizedLower.includes('base64,ffd8')) return false + if (normalizedLower.startsWith('ffd8')) return false + return true + } + /** * 连接数据库 */ @@ -255,6 +362,7 @@ class ChatService { // 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW) // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { + this.handleSessionStatsMonitorChange(type, json) // 广播给所有渲染进程窗口 BrowserWindow.getAllWindows().forEach((win) => { if (!win.isDestroyed()) { @@ -342,6 +450,7 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error } } + this.refreshSessionMessageCountCacheScope() const result = await wcdbService.getSessions() if (!result.success || !result.sessions) { @@ -357,7 +466,7 @@ class ChatService { return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } } - // 转换为 ChatSession(先加载缓存,但不等待数据库查询) + // 转换为 ChatSession(先加载缓存,但不等待额外状态查询) const sessions: ChatSession[] = [] const now = Date.now() const myWxid = this.configService.get('myWxid') @@ -395,6 +504,21 @@ class ChatService { const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) + const messageCountHintRaw = + row.message_count ?? + row.messageCount ?? + row.msg_count ?? + row.msgCount ?? + row.total_count ?? + row.totalCount ?? + row.n_msg ?? + row.nMsg ?? + row.message_num ?? + row.messageNum + const parsedMessageCountHint = Number(messageCountHintRaw) + const messageCountHint = Number.isFinite(parsedMessageCountHint) && parsedMessageCountHint >= 0 + ? Math.floor(parsedMessageCountHint) + : undefined // 先尝试从缓存获取联系人信息(快速路径) let displayName = username @@ -405,7 +529,7 @@ class ChatService { avatarUrl = cached.avatarUrl } - sessions.push({ + const nextSession: ChatSession = { username, type: parseInt(row.type || '0', 10), unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10), @@ -413,29 +537,29 @@ class ChatService { sortTimestamp: sortTs, lastTimestamp: lastTs, lastMsgType, + messageCountHint, displayName, avatarUrl, lastMsgSender: row.last_msg_sender, lastSenderDisplayName: row.last_sender_display_name, selfWxid: myWxid - }) - } - - // 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程 - const allUsernames = sessions.map(s => s.username) - try { - const statusResult = await wcdbService.getContactStatus(allUsernames) - if (statusResult.success && statusResult.map) { - for (const s of sessions) { - const st = statusResult.map[s.username] - if (st) { - s.isFolded = st.isFolded - s.isMuted = st.isMuted - } - } } - } catch { - // 状态获取失败不影响会话列表返回 + + const cachedStatus = this.sessionStatusCache.get(username) + if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) { + nextSession.isFolded = cachedStatus.isFolded + nextSession.isMuted = cachedStatus.isMuted + } + + sessions.push(nextSession) + + if (typeof messageCountHint === 'number') { + this.sessionMessageCountHintCache.set(username, messageCountHint) + this.sessionMessageCountCache.set(username, { + count: messageCountHint, + updatedAt: Date.now() + }) + } } // 不等待联系人信息加载,直接返回基础会话列表 @@ -447,18 +571,70 @@ class ChatService { } } + async getSessionStatuses(usernames: string[]): Promise<{ + success: boolean + map?: Record + error?: string + }> { + try { + if (!Array.isArray(usernames) || usernames.length === 0) { + return { success: true, map: {} } + } + + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const result = await wcdbService.getContactStatus(usernames) + if (!result.success || !result.map) { + return { success: false, error: result.error || '获取会话状态失败' } + } + + const now = Date.now() + for (const username of usernames) { + const state = result.map[username] + if (!state) continue + this.sessionStatusCache.set(username, { + isFolded: state.isFolded, + isMuted: state.isMuted, + updatedAt: now + }) + } + + return { + success: true, + map: result.map as Record + } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 异步补充会话列表的联系人信息(公开方法,供前端调用) */ - async enrichSessionsContactInfo(usernames: string[]): Promise<{ + async enrichSessionsContactInfo( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ): Promise<{ success: boolean contacts?: Record error?: string }> { try { - if (usernames.length === 0) { + const normalizedUsernames = Array.from( + new Set( + (usernames || []) + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) { return { success: true, contacts: {} } } + const skipDisplayName = options?.skipDisplayName === true + const onlyMissingAvatar = options?.onlyMissingAvatar === true const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -471,16 +647,23 @@ class ChatService { const updatedEntries: Record = {} // 检查缓存 - for (const username of usernames) { + for (const username of normalizedUsernames) { const cached = this.avatarCache.get(username) + const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) + const cachedAvatarUrl = isValidAvatar ? cached?.avatarUrl : undefined + if (onlyMissingAvatar && cachedAvatarUrl) { + result[username] = { + displayName: skipDisplayName ? undefined : cached?.displayName, + avatarUrl: cachedAvatarUrl + } + continue + } // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - const isValidAvatar = cached?.avatarUrl && - !cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式 if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { - displayName: cached.displayName, - avatarUrl: cached.avatarUrl + displayName: skipDisplayName ? undefined : cached.displayName, + avatarUrl: cachedAvatarUrl } } else { missing.push(username) @@ -489,16 +672,19 @@ class ChatService { // 批量查询缺失的联系人信息 if (missing.length > 0) { - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(missing), - wcdbService.getAvatarUrls(missing) - ]) + const displayNames = skipDisplayName + ? null + : await wcdbService.getDisplayNames(missing) + const avatarUrls = await wcdbService.getAvatarUrls(missing) // 收集没有头像 URL 的用户名 const missingAvatars: string[] = [] for (const username of missing) { - const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined + const previous = this.avatarCache.get(username) + const displayName = displayNames?.success && displayNames.map + ? displayNames.map[username] + : undefined let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined // 如果没有头像 URL,记录下来稍后从 head_image.db 获取 @@ -507,11 +693,14 @@ class ChatService { } const cacheEntry: ContactCacheEntry = { - displayName: displayName || username, + displayName: displayName || previous?.displayName || username, avatarUrl, updatedAt: now } - result[username] = { displayName, avatarUrl } + result[username] = { + displayName: skipDisplayName ? undefined : (displayName || previous?.displayName), + avatarUrl + } // 更新缓存并记录持久化 this.avatarCache.set(username, cacheEntry) updatedEntries[username] = cacheEntry @@ -576,40 +765,52 @@ class ChatService { if (!headImageDbPath) return result - // 使用 wcdbService.execQuery 查询加密的 head_image.db - for (const username of usernames) { - try { - const escapedUsername = username.replace(/'/g, "''") - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` - ) + const normalizedUsernames = Array.from( + new Set( + usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) return result - if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { - const row = queryResult.rows[0] as any - if (row?.image_buffer) { - let base64Data: string - 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 - } - } 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') - } else { - continue - } - result[username] = `data:image/jpeg;base64,${base64Data}` + const batchSize = 320 + 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 + } + + 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 } + } 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 { - // 静默处理单个用户的错误 } } } catch (e) { @@ -641,6 +842,479 @@ class ChatService { } } + /** + * 获取联系人类型数量(好友、群聊、公众号、曾经的好友) + */ + async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + 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) { + 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) + } + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 获取联系人类型数量失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + return this.getContactTypeCounts() + } + + private async listMessageDbPathsForCount(): Promise<{ success: boolean; dbPaths?: string[]; error?: string }> { + try { + const result = await wcdbService.listMessageDbs() + if (!result.success) { + return { success: false, error: result.error || '获取消息数据库列表失败' } + } + const normalized = Array.from(new Set( + (result.data || []) + .map(pathItem => String(pathItem || '').trim()) + .filter(Boolean) + )) + return { success: true, dbPaths: normalized } + } catch (e) { + return { success: false, error: String(e) } + } + } + + private buildMessageDbSignature(dbPaths: string[]): string { + if (!Array.isArray(dbPaths) || dbPaths.length === 0) return 'empty' + const parts: string[] = [] + const sortedPaths = [...dbPaths].sort() + for (const dbPath of sortedPaths) { + try { + const stat = statSync(dbPath) + parts.push(`${dbPath}:${stat.size}:${Math.floor(stat.mtimeMs)}`) + } catch { + parts.push(`${dbPath}:missing`) + } + } + return parts.join('|') + } + + private buildSessionHashLookup(sessionIds: string[]): { + full32: Map + short16: Map + } { + const full32 = new Map() + const short16 = new Map() + for (const sessionId of sessionIds) { + const hash = crypto.createHash('md5').update(sessionId).digest('hex').toLowerCase() + full32.set(hash, sessionId) + const shortHash = hash.slice(0, 16) + const existing = short16.get(shortHash) + if (existing === undefined) { + short16.set(shortHash, sessionId) + } else if (existing !== sessionId) { + short16.set(shortHash, null) + } + } + return { full32, short16 } + } + + private matchSessionIdByTableName( + tableName: string, + hashLookup: { + full32: Map + short16: Map + } + ): string | null { + const normalized = String(tableName || '').trim().toLowerCase() + if (!normalized.startsWith('msg_')) return null + const suffix = normalized.slice(4) + + const directFull = hashLookup.full32.get(suffix) + if (directFull) return directFull + + if (suffix.length >= 16) { + const shortCandidate = hashLookup.short16.get(suffix.slice(0, 16)) + if (typeof shortCandidate === 'string') return shortCandidate + } + + const hashMatch = normalized.match(/[a-f0-9]{32}|[a-f0-9]{16}/i) + if (!hashMatch || !hashMatch[0]) return null + const matchedHash = hashMatch[0].toLowerCase() + if (matchedHash.length >= 32) { + const full = hashLookup.full32.get(matchedHash) + if (full) return full + } + const short = hashLookup.short16.get(matchedHash.slice(0, 16)) + return typeof short === 'string' ? short : null + } + + private quoteSqlIdentifier(identifier: string): string { + return `"${String(identifier || '').replace(/"/g, '""')}"` + } + + private async countSessionMessageCountsByTableScan( + sessionIds: string[], + traceId?: string + ): Promise<{ + success: boolean + counts?: Record + error?: string + dbSignature?: string + }> { + const normalizedSessionIds = Array.from(new Set( + (sessionIds || []) + .map(id => String(id || '').trim()) + .filter(Boolean) + )) + if (normalizedSessionIds.length === 0) { + return { success: true, counts: {}, dbSignature: 'empty' } + } + + const dbPathsResult = await this.listMessageDbPathsForCount() + if (!dbPathsResult.success) { + return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } + } + 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 + 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, + level: 'debug', + source: 'backend', + stepId: 'backend-get-session-message-counts-table-scan', + stepName: '会话消息总数表扫描', + status: 'done', + message: '按 Msg 表聚合统计完成', + data: { + dbCount: dbPaths.length, + requestedSessions: normalizedSessionIds.length + } + }) + + return { success: true, counts, dbSignature } + } + + /** + * 批量获取会话消息总数(轻量接口,用于列表优先排序) + */ + async getSessionMessageCounts( + sessionIds: string[], + options?: { preferHintCache?: boolean; bypassSessionCache?: boolean; traceId?: string } + ): Promise<{ + success: boolean + counts?: Record + error?: string + }> { + const traceId = this.normalizeExportDiagTraceId(options?.traceId) + const stepStartedAt = this.startExportDiagStep({ + traceId, + stepId: 'backend-get-session-message-counts', + stepName: 'ChatService.getSessionMessageCounts', + message: '开始批量读取会话消息总数', + data: { + requestedSessions: Array.isArray(sessionIds) ? sessionIds.length : 0, + preferHintCache: options?.preferHintCache !== false, + bypassSessionCache: options?.bypassSessionCache === true + } + }) + let success = false + let errorMessage = '' + let returnedCounts = 0 + + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + errorMessage = connectResult.error || '数据库未连接' + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + success = true + return { success: true, counts: {} } + } + + const preferHintCache = options?.preferHintCache !== false + const bypassSessionCache = options?.bypassSessionCache === true + + this.refreshSessionMessageCountCacheScope() + const counts: Record = {} + const now = Date.now() + const pendingSessionIds: string[] = [] + const sessionIdsKey = [...normalizedSessionIds].sort().join('\u0001') + + for (const sessionId of normalizedSessionIds) { + if (!bypassSessionCache) { + const cached = this.sessionMessageCountCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { + counts[sessionId] = cached.count + continue + } + } + + if (preferHintCache) { + const hintCount = this.sessionMessageCountHintCache.get(sessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + counts[sessionId] = Math.floor(hintCount) + this.sessionMessageCountCache.set(sessionId, { + count: Math.floor(hintCount), + updatedAt: now + }) + continue + } + } + + pendingSessionIds.push(sessionId) + } + + if (pendingSessionIds.length > 0) { + let tableScanSucceeded = false + const cachedBatch = this.sessionMessageCountBatchCache + const cachedBatchFresh = cachedBatch && + 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 + } + } + } + + if (!tableScanSucceeded) { + const tableScanResult = await this.countSessionMessageCountsByTableScan(pendingSessionIds, traceId) + if (tableScanResult.success && tableScanResult.counts) { + const nowTs = Date.now() + for (const sessionId of pendingSessionIds) { + const nextCountRaw = tableScanResult.counts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: nowTs + }) + } + if (tableScanResult.dbSignature) { + this.sessionMessageCountBatchCache = { + dbSignature: tableScanResult.dbSignature, + sessionIdsKey, + counts: { ...counts }, + updatedAt: nowTs + } + } + tableScanSucceeded = true + } else { + this.logExportDiag({ + traceId, + level: 'warn', + source: 'backend', + stepId: 'backend-get-session-message-counts-table-scan', + stepName: '会话消息总数表扫描', + status: 'failed', + message: '按 Msg 表聚合统计失败,回退逐会话统计', + data: { + error: tableScanResult.error || '未知错误' + } + }) + } + } + + if (!tableScanSucceeded) { + const batchSize = 320 + for (let i = 0; i < pendingSessionIds.length; i += batchSize) { + const batch = pendingSessionIds.slice(i, i + batchSize) + this.logExportDiag({ + traceId, + level: 'debug', + source: 'backend', + stepId: 'backend-get-session-message-counts-batch', + stepName: '会话消息总数批次查询', + status: 'running', + message: `开始查询批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(pendingSessionIds.length / batchSize) || 1}`, + data: { + batchSize: batch.length + } + }) + let batchCounts: Record = {} + try { + const result = await wcdbService.getMessageCounts(batch) + if (result.success && result.counts) { + batchCounts = result.counts + } + } catch { + // noop + } + + const nowTs = Date.now() + for (const sessionId of batch) { + const nextCountRaw = batchCounts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: nowTs + }) + } + } + } + } + + returnedCounts = Object.keys(counts).length + success = true + return { success: true, counts } + } catch (e) { + console.error('ChatService: 批量获取会话消息总数失败:', e) + errorMessage = String(e) + return { success: false, error: String(e) } + } finally { + this.endExportDiagStep({ + traceId, + stepId: 'backend-get-session-message-counts', + stepName: 'ChatService.getSessionMessageCounts', + startedAt: stepStartedAt, + success, + message: success ? '批量会话消息总数读取完成' : '批量会话消息总数读取失败', + data: success ? { returnedCounts } : { error: errorMessage || '未知错误' } + }) + } + } + /** * 获取通讯录列表 */ @@ -654,11 +1328,17 @@ class ChatService { // 使用execQuery直接查询加密的contact.db // kind='contact', path=null表示使用已打开的contact.db const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, flag, quan_pin + 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) if (!contactResult.success || !contactResult.rows) { @@ -668,21 +1348,6 @@ class ChatService { const rows = contactResult.rows as Record[] - - // 调试:显示前5条数据样本 - - rows.slice(0, 5).forEach((row, idx) => { - - }) - - // 调试:统计local_type分布 - const localTypeStats = new Map() - rows.forEach(row => { - const lt = row.local_type || 0 - localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) - }) - - // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -698,25 +1363,24 @@ class ChatService { // 转换为ContactInfo const contacts: (ContactInfo & { lastContactTime: number })[] = [] + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) for (const row of rows) { - const username = row.username || '' + const username = String(row.username || '').trim() if (!username) continue - const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'] let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const flag = Number(row.flag ?? 0) - const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '' + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (username.includes('@chatroom')) { + if (username.endsWith('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { type = 'official' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) { + } else if (localType === 1 && !excludeNames.has(username)) { type = 'friend' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) { + } else if (localType === 0 && quanPin) { type = 'former_friend' } else { continue @@ -729,6 +1393,7 @@ class ChatService { displayName, remark: row.remark || undefined, nickname: row.nick_name || undefined, + alias: row.alias || undefined, avatarUrl: undefined, type, lastContactTime: lastContactTimeMap.get(username) || 0 @@ -772,6 +1437,7 @@ class ChatService { endTime: number = 0, ascending: boolean = false ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { + let releaseMessageCursorMutex: (() => void) | null = null try { const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -785,6 +1451,12 @@ class ChatService { await new Promise(resolve => setTimeout(resolve, 1)) } this.messageCursorMutex = true + let mutexReleased = false + releaseMessageCursorMutex = () => { + if (mutexReleased) return + this.messageCursorMutex = false + mutexReleased = true + } let state = this.messageCursors.get(sessionId) @@ -823,7 +1495,7 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) - this.messageCursorMutex = false + releaseMessageCursorMutex?.() // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; @@ -856,7 +1528,6 @@ class ChatService { } skipped += count - state.fetched += count // If satisfied offset, break if (skipped >= offset) break; @@ -869,6 +1540,7 @@ class ChatService { if (attempts >= maxSkipAttempts) { console.error(`[ChatService] 跳过消息超过最大尝试次数: attempts=${attempts}`) } + state.fetched = offset console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}, buffered=${state.bufferedMessages?.length || 0}`) } } @@ -894,7 +1566,6 @@ class ChatService { const nextBatch = await wcdbService.fetchMessageBatch(state.cursor) if (nextBatch.success && nextBatch.rows) { rows = rows.concat(nextBatch.rows) - state.fetched += nextBatch.rows.length actualHasMore = nextBatch.hasMore === true } else if (!nextBatch.success) { console.error('[ChatService] 获取消息批次失败:', nextBatch.error) @@ -961,14 +1632,15 @@ class ChatService { } state.fetched += rows.length - this.messageCursorMutex = false + releaseMessageCursorMutex?.() this.messageCacheService.set(sessionId, filtered) return { success: true, messages: filtered, hasMore } } catch (e) { - this.messageCursorMutex = false console.error('ChatService: 获取消息失败:', e) return { success: false, error: String(e) } + } finally { + releaseMessageCursorMutex?.() } } @@ -1063,7 +1735,7 @@ class ChatService { } - async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { try { const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -1094,7 +1766,7 @@ class ChatService { await Promise.allSettled(fixPromises) } - return { success: true, messages: normalized } + return { success: true, messages: normalized, hasMore: batch.hasMore === true } } finally { await wcdbService.closeMessageCursor(cursorResult.cursor) } @@ -1210,6 +1882,1066 @@ class ChatService { return Number.isFinite(parsed) ? parsed : NaN } + private buildIdentityKeys(raw: string): string[] { + const value = String(raw || '').trim() + if (!value) return [] + const lowerRaw = value.toLowerCase() + const cleaned = this.cleanAccountDirName(value).toLowerCase() + if (cleaned && cleaned !== lowerRaw) { + return [cleaned, lowerRaw] + } + return [lowerRaw] + } + + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + 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) { + return identities + } + + for (const rowAny of contactResult.rows) { + const row = rowAny as Record + const username = String(row.username || '').trim() + if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue + if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + if (localType !== 1) continue + + for (const key of this.buildIdentityKeys(username)) { + identities.add(key) + } + } + return identities + } + + private async forEachWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise + ): Promise { + if (items.length === 0) return + const concurrency = Math.max(1, Math.min(limit, items.length)) + let index = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const current = index + index += 1 + if (current >= items.length) return + await worker(items[current]) + } + }) + + await Promise.all(runners) + } + + private normalizeExportDiagTraceId(traceId?: string): string { + const normalized = String(traceId || '').trim() + return normalized + } + + private logExportDiag(input: { + traceId?: string + source?: 'backend' | 'main' | 'frontend' | 'worker' + level?: 'debug' | 'info' | 'warn' | 'error' + message: string + stepId?: string + stepName?: string + status?: 'running' | 'done' | 'failed' | 'timeout' + durationMs?: number + data?: Record + }): void { + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (!traceId) return + exportCardDiagnosticsService.log({ + traceId, + source: input.source || 'backend', + level: input.level || 'info', + message: input.message, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: input.durationMs, + data: input.data + }) + } + + private startExportDiagStep(input: { + traceId?: string + stepId: string + stepName: string + message: string + data?: Record + }): number { + const startedAt = Date.now() + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (traceId) { + exportCardDiagnosticsService.stepStart({ + traceId, + stepId: input.stepId, + stepName: input.stepName, + source: 'backend', + message: input.message, + data: input.data + }) + } + return startedAt + } + + private endExportDiagStep(input: { + traceId?: string + stepId: string + stepName: string + startedAt: number + success: boolean + message?: string + data?: Record + }): void { + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (!traceId) return + exportCardDiagnosticsService.stepEnd({ + traceId, + stepId: input.stepId, + stepName: input.stepName, + source: 'backend', + status: input.success ? 'done' : 'failed', + message: input.message || (input.success ? `${input.stepName} 完成` : `${input.stepName} 失败`), + durationMs: Math.max(0, Date.now() - input.startedAt), + data: input.data + }) + } + + private refreshSessionMessageCountCacheScope(): void { + const dbPath = String(this.configService.get('dbPath') || '') + const myWxid = String(this.configService.get('myWxid') || '') + const scope = `${dbPath}::${myWxid}` + if (scope === this.sessionMessageCountCacheScope) { + this.refreshSessionStatsCacheScope(scope) + this.refreshGroupMyMessageCountCacheScope(scope) + return + } + this.sessionMessageCountCacheScope = scope + this.sessionMessageCountCache.clear() + this.sessionMessageCountHintCache.clear() + this.sessionMessageCountBatchCache = null + this.sessionDetailFastCache.clear() + this.sessionDetailExtraCache.clear() + this.sessionStatusCache.clear() + this.messageTableColumnsCache.clear() + this.refreshSessionStatsCacheScope(scope) + this.refreshGroupMyMessageCountCacheScope(scope) + } + + private refreshGroupMyMessageCountCacheScope(scope: string): void { + if (scope === this.groupMyMessageCountCacheScope) return + this.groupMyMessageCountCacheScope = scope + this.groupMyMessageCountMemoryCache.clear() + } + + private refreshSessionStatsCacheScope(scope: string): void { + if (scope === this.sessionStatsCacheScope) return + this.sessionStatsCacheScope = scope + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + } + + private buildScopedSessionStatsKey(sessionId: string): string { + return `${this.sessionStatsCacheScope}::${sessionId}` + } + + private buildScopedGroupMyMessageCountKey(chatroomId: string): string { + return `${this.groupMyMessageCountCacheScope}::${chatroomId}` + } + + private getGroupMyMessageCountHintEntry( + chatroomId: string + ): { entry: GroupMyMessageCountCacheEntry; source: 'memory' | 'disk' } | null { + const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) + const inMemory = this.groupMyMessageCountMemoryCache.get(scopedKey) + if (inMemory) { + return { entry: inMemory, source: 'memory' } + } + + const persisted = this.groupMyMessageCountCacheService.get(this.groupMyMessageCountCacheScope, chatroomId) + if (!persisted) return null + this.groupMyMessageCountMemoryCache.set(scopedKey, persisted) + return { entry: persisted, source: 'disk' } + } + + private setGroupMyMessageCountHintEntry(chatroomId: string, messageCount: number, updatedAt?: number): number { + const nextCount = Number.isFinite(messageCount) ? Math.max(0, Math.floor(messageCount)) : 0 + const nextUpdatedAt = Number.isFinite(updatedAt) ? Math.max(0, Math.floor(updatedAt as number)) : Date.now() + const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) + const existing = this.groupMyMessageCountMemoryCache.get(scopedKey) + if (existing && existing.updatedAt > nextUpdatedAt) { + return existing.updatedAt + } + + const entry: GroupMyMessageCountCacheEntry = { + updatedAt: nextUpdatedAt, + messageCount: nextCount + } + this.groupMyMessageCountMemoryCache.set(scopedKey, entry) + this.groupMyMessageCountCacheService.set(this.groupMyMessageCountCacheScope, chatroomId, entry) + return nextUpdatedAt + } + + private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats { + const normalized: SessionStatsCacheStats = { + totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0, + voiceMessages: Number.isFinite(stats.voiceMessages) ? Math.max(0, Math.floor(stats.voiceMessages)) : 0, + imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0, + videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0, + emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0, + transferMessages: Number.isFinite(stats.transferMessages) ? Math.max(0, Math.floor(stats.transferMessages)) : 0, + redPacketMessages: Number.isFinite(stats.redPacketMessages) ? Math.max(0, Math.floor(stats.redPacketMessages)) : 0, + callMessages: Number.isFinite(stats.callMessages) ? Math.max(0, Math.floor(stats.callMessages)) : 0 + } + + if (Number.isFinite(stats.firstTimestamp)) normalized.firstTimestamp = Math.max(0, Math.floor(stats.firstTimestamp as number)) + if (Number.isFinite(stats.lastTimestamp)) normalized.lastTimestamp = Math.max(0, Math.floor(stats.lastTimestamp as number)) + if (Number.isFinite(stats.privateMutualGroups)) normalized.privateMutualGroups = Math.max(0, Math.floor(stats.privateMutualGroups as number)) + if (Number.isFinite(stats.groupMemberCount)) normalized.groupMemberCount = Math.max(0, Math.floor(stats.groupMemberCount as number)) + if (Number.isFinite(stats.groupMyMessages)) normalized.groupMyMessages = Math.max(0, Math.floor(stats.groupMyMessages as number)) + if (Number.isFinite(stats.groupActiveSpeakers)) normalized.groupActiveSpeakers = Math.max(0, Math.floor(stats.groupActiveSpeakers as number)) + if (Number.isFinite(stats.groupMutualFriends)) normalized.groupMutualFriends = Math.max(0, Math.floor(stats.groupMutualFriends as number)) + + return normalized + } + + private fromSessionStatsCacheStats(stats: SessionStatsCacheStats): ExportSessionStats { + return { + totalMessages: stats.totalMessages, + voiceMessages: stats.voiceMessages, + imageMessages: stats.imageMessages, + videoMessages: stats.videoMessages, + emojiMessages: stats.emojiMessages, + transferMessages: stats.transferMessages, + redPacketMessages: stats.redPacketMessages, + callMessages: stats.callMessages, + firstTimestamp: stats.firstTimestamp, + lastTimestamp: stats.lastTimestamp, + privateMutualGroups: stats.privateMutualGroups, + groupMemberCount: stats.groupMemberCount, + groupMyMessages: stats.groupMyMessages, + groupActiveSpeakers: stats.groupActiveSpeakers, + groupMutualFriends: stats.groupMutualFriends + } + } + + private supportsRequestedRelation(entry: SessionStatsCacheEntry, includeRelations: boolean): boolean { + if (!includeRelations) return true + return entry.includeRelations + } + + private getSessionStatsCacheEntry(sessionId: string): { entry: SessionStatsCacheEntry; source: 'memory' | 'disk' } | null { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + const inMemory = this.sessionStatsMemoryCache.get(scopedKey) + if (inMemory) { + return { entry: inMemory, source: 'memory' } + } + + const persisted = this.sessionStatsCacheService.get(this.sessionStatsCacheScope, sessionId) + if (!persisted) return null + this.sessionStatsMemoryCache.set(scopedKey, persisted) + return { entry: persisted, source: 'disk' } + } + + private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number { + const updatedAt = Date.now() + const normalizedStats = this.toSessionStatsCacheStats(stats) + const entry: SessionStatsCacheEntry = { + updatedAt, + includeRelations, + stats: normalizedStats + } + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + this.sessionStatsMemoryCache.set(scopedKey, entry) + this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry) + if (sessionId.endsWith('@chatroom') && Number.isFinite(normalizedStats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, normalizedStats.groupMyMessages as number, updatedAt) + } + return updatedAt + } + + private deleteSessionStatsCacheEntry(sessionId: string): void { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + this.sessionStatsMemoryCache.delete(scopedKey) + this.sessionStatsPendingBasic.delete(scopedKey) + this.sessionStatsPendingFull.delete(scopedKey) + this.sessionStatsCacheService.delete(this.sessionStatsCacheScope, sessionId) + } + + private clearSessionStatsCacheForScope(): void { + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + this.sessionStatsCacheService.clearScope(this.sessionStatsCacheScope) + } + + private collectSessionIdsFromPayload(payload: unknown): Set { + const ids = new Set() + const walk = (value: unknown, keyHint?: string) => { + if (Array.isArray(value)) { + for (const item of value) walk(item, keyHint) + return + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + walk(v, k) + } + return + } + if (typeof value !== 'string') return + const normalized = value.trim() + if (!normalized) return + const lowerKey = String(keyHint || '').toLowerCase() + const keyLooksLikeSession = ( + lowerKey.includes('session') || + lowerKey.includes('talker') || + lowerKey.includes('username') || + lowerKey.includes('chatroom') + ) + if (!keyLooksLikeSession && !normalized.includes('@chatroom')) { + return + } + ids.add(normalized) + } + walk(payload) + return ids + } + + private handleSessionStatsMonitorChange(type: string, json: string): void { + this.refreshSessionMessageCountCacheScope() + if (!this.sessionStatsCacheScope) return + + const normalizedType = String(type || '').toLowerCase() + const maybeJson = String(json || '').trim() + let ids = new Set() + if (maybeJson) { + try { + ids = this.collectSessionIdsFromPayload(JSON.parse(maybeJson)) + } catch { + ids = this.collectSessionIdsFromPayload(maybeJson) + } + } + + if (ids.size > 0) { + ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId)) + if (Array.from(ids).some((id) => id.includes('@chatroom'))) { + this.allGroupSessionIdsCache = null + } + return + } + + // 无法定位具体会话时,保守地仅在消息/群成员相关变更时清空当前 scope,避免展示过旧统计。 + if ( + normalizedType.includes('message') || + normalizedType.includes('session') || + normalizedType.includes('group') || + normalizedType.includes('member') || + normalizedType.includes('contact') + ) { + this.clearSessionStatsCacheForScope() + } + } + + private async listAllGroupSessionIds(): Promise { + const now = Date.now() + if ( + this.allGroupSessionIdsCache && + now - this.allGroupSessionIdsCache.updatedAt <= this.allGroupSessionIdsCacheTtlMs + ) { + return this.allGroupSessionIdsCache.ids + } + + const result = await wcdbService.getSessions() + if (!result.success || !Array.isArray(result.sessions)) { + return [] + } + + const ids = new Set() + for (const rowAny of result.sessions) { + const row = rowAny as Record + const usernameRaw = row.username ?? row.userName ?? row.talker ?? row.sessionId + const username = String(usernameRaw || '').trim() + if (!username || !username.endsWith('@chatroom')) continue + ids.add(username) + } + + const list = Array.from(ids) + this.allGroupSessionIdsCache = { + ids: list, + updatedAt: now + } + return list + } + + private async getSessionMessageTables(sessionId: string): Promise> { + const cached = this.sessionTablesCache.get(sessionId) + if (cached && cached.length > 0) { + return cached + } + + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return [] + } + + const 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) + } + return tables + } + + private async getMessageTableColumns(dbPath: string, tableName: string): Promise> { + const cacheKey = `${dbPath}\u0001${tableName}` + const now = Date.now() + const cached = this.messageTableColumnsCache.get(cacheKey) + if (cached && now - cached.updatedAt <= this.messageTableColumnsCacheTtlMs) { + 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 columns = new Set() + for (const row of result.rows as Record[]) { + const name = String(this.getRowField(row, ['name', 'column_name', 'columnName']) || '').trim().toLowerCase() + if (name) columns.add(name) + } + this.messageTableColumnsCache.set(cacheKey, { + columns: new Set(columns), + updatedAt: now + }) + return columns + } + + private pickFirstColumn(columns: Set, candidates: string[]): string | undefined { + for (const candidate of candidates) { + const normalized = candidate.toLowerCase() + if (columns.has(normalized)) return normalized + } + return undefined + } + + private escapeSqlLiteral(value: string): string { + return String(value || '').replace(/'/g, "''") + } + + private extractType49XmlTypeForStats(content: string): string { + if (!content) return '' + + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) return String(typeMatch[1] || '').trim() + } + + return this.extractXmlValue(content, 'type') + } + + private async collectSpecialMessageCountsByCursorScan(sessionId: string): Promise<{ + transferMessages: number + redPacketMessages: number + callMessages: number + }> { + const counters = { + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return counters + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) break + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 50) { + counters.callMessages += 1 + continue + } + if (localType === 8589934592049) { + counters.transferMessages += 1 + continue + } + if (localType === 8594229559345) { + counters.redPacketMessages += 1 + continue + } + if (localType !== 49) continue + + const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) + const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + const xmlType = this.extractType49XmlTypeForStats(content) + if (xmlType === '2000') counters.transferMessages += 1 + if (xmlType === '2001') counters.redPacketMessages += 1 + } + + if (!batch.hasMore || rows.length === 0) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + return counters + } + + private async collectSessionExportStatsByCursorScan( + sessionId: string, + selfIdentitySet: Set + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0, + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + } + + const senderIdentities = new Set() + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return stats + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + break + } + + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + stats.totalMessages += 1 + + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 34) stats.voiceMessages += 1 + if (localType === 3) stats.imageMessages += 1 + if (localType === 43) stats.videoMessages += 1 + if (localType === 47) stats.emojiMessages += 1 + if (localType === 50) stats.callMessages += 1 + if (localType === 8589934592049) stats.transferMessages += 1 + if (localType === 8594229559345) stats.redPacketMessages += 1 + if (localType === 49) { + const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) + const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + const xmlType = this.extractType49XmlTypeForStats(content) + if (xmlType === '2000') stats.transferMessages += 1 + if (xmlType === '2001') stats.redPacketMessages += 1 + } + + const createTime = this.getRowInt( + row, + ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + 0 + ) + if (createTime > 0) { + if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) { + stats.firstTimestamp = createTime + } + if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) { + stats.lastTimestamp = createTime + } + } + + if (sessionId.endsWith('@chatroom')) { + const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const senderKeys = this.buildIdentityKeys(sender) + if (senderKeys.length > 0) { + senderIdentities.add(senderKeys[0]) + if (senderKeys.some((key) => selfIdentitySet.has(key))) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } else { + const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + if (Number.isFinite(isSend) && isSend === 1) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } + } + } + + if (!batch.hasMore || rows.length === 0) { + break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + if (sessionId.endsWith('@chatroom')) { + stats.groupActiveSpeakers = senderIdentities.size + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private async collectSessionExportStats( + sessionId: string, + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0, + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + if (sessionId.endsWith('@chatroom')) { + 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) { + return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet) + } + + if (preferAccurateSpecialTypes) { + try { + const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId) + stats.transferMessages = preciseCounters.transferMessages + stats.redPacketMessages = preciseCounters.redPacketMessages + stats.callMessages = preciseCounters.callMessages + } catch { + // 保留聚合统计结果作为兜底 + } + } + + if (isGroup) { + stats.groupActiveSpeakers = senderIdentities.size + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private async buildGroupRelationStats( + groupSessionIds: string[], + privateSessionIds: string[], + selfIdentitySet: Set + ): Promise<{ + privateMutualGroupMap: Record + groupMutualFriendMap: Record + }> { + const privateMutualGroupMap: Record = {} + const groupMutualFriendMap: Record = {} + if (groupSessionIds.length === 0) { + return { privateMutualGroupMap, groupMutualFriendMap } + } + + const privateIndex = new Map>() + for (const sessionId of privateSessionIds) { + for (const key of this.buildIdentityKeys(sessionId)) { + const set = privateIndex.get(key) || new Set() + set.add(sessionId) + privateIndex.set(key, set) + } + privateMutualGroupMap[sessionId] = 0 + } + + const friendIdentitySet = await this.getFriendIdentitySet() + await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => { + const membersResult = await wcdbService.getGroupMembers(groupId) + if (!membersResult.success || !membersResult.members) { + groupMutualFriendMap[groupId] = 0 + return + } + + const touchedPrivateSessions = new Set() + const friendMembers = new Set() + + for (const member of membersResult.members) { + const username = this.extractGroupMemberUsername(member) + const identityKeys = this.buildIdentityKeys(username) + if (identityKeys.length === 0) continue + const canonical = identityKeys[0] + + if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) { + friendMembers.add(canonical) + } + + for (const key of identityKeys) { + const linked = privateIndex.get(key) + if (!linked) continue + for (const sessionId of linked) { + touchedPrivateSessions.add(sessionId) + } + } + } + + groupMutualFriendMap[groupId] = friendMembers.size + for (const sessionId of touchedPrivateSessions) { + privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1 + } + }) + + return { privateMutualGroupMap, groupMutualFriendMap } + } + + private buildEmptyExportSessionStats(sessionId: string, includeRelations: boolean): ExportSessionStats { + const isGroup = sessionId.endsWith('@chatroom') + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0, + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + if (isGroup) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + stats.groupMemberCount = 0 + if (includeRelations) { + stats.groupMutualFriends = 0 + } + } else if (includeRelations) { + stats.privateMutualGroups = 0 + } + return stats + } + + private async computeSessionExportStats( + sessionId: string, + selfIdentitySet: Set, + includeRelations: boolean, + preferAccurateSpecialTypes: boolean = false + ): Promise { + const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) + const isGroup = sessionId.endsWith('@chatroom') + + if (isGroup) { + const memberCountsResult = await wcdbService.getGroupMemberCounts([sessionId]) + const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) : 0 + } + + if (includeRelations) { + if (isGroup) { + try { + const { groupMutualFriendMap } = await this.buildGroupRelationStats([sessionId], [], selfIdentitySet) + stats.groupMutualFriends = groupMutualFriendMap[sessionId] || 0 + } catch { + stats.groupMutualFriends = 0 + } + } else { + const allGroups = await this.listAllGroupSessionIds() + if (allGroups.length === 0) { + stats.privateMutualGroups = 0 + } else { + try { + const { privateMutualGroupMap } = await this.buildGroupRelationStats(allGroups, [sessionId], selfIdentitySet) + stats.privateMutualGroups = privateMutualGroupMap[sessionId] || 0 + } catch { + stats.privateMutualGroups = 0 + } + } + } + } + + return stats + } + + private async computeSessionExportStatsBatch( + sessionIds: string[], + includeRelations: boolean, + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false + ): Promise> { + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + const result: Record = {} + if (normalizedSessionIds.length === 0) { + return result + } + + const groupSessionIds = normalizedSessionIds.filter(sessionId => sessionId.endsWith('@chatroom')) + const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom')) + + let memberCountMap: Record = {} + if (groupSessionIds.length > 0) { + try { + const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) + memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + } catch { + memberCountMap = {} + } + } + + let privateMutualGroupMap: Record = {} + let groupMutualFriendMap: Record = {} + if (includeRelations) { + let relationGroupSessionIds: string[] = [] + if (privateSessionIds.length > 0) { + const allGroups = await this.listAllGroupSessionIds() + relationGroupSessionIds = Array.from(new Set([...allGroups, ...groupSessionIds])) + } else if (groupSessionIds.length > 0) { + relationGroupSessionIds = groupSessionIds + } + + if (relationGroupSessionIds.length > 0) { + try { + const relation = await this.buildGroupRelationStats( + relationGroupSessionIds, + privateSessionIds, + selfIdentitySet + ) + privateMutualGroupMap = relation.privateMutualGroupMap || {} + groupMutualFriendMap = relation.groupMutualFriendMap || {} + } catch { + privateMutualGroupMap = {} + groupMutualFriendMap = {} + } + } + } + + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { + try { + const stats = 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 (includeRelations) { + stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number' + ? Math.max(0, Math.floor(groupMutualFriendMap[sessionId])) + : 0 + } + } else if (includeRelations) { + stats.privateMutualGroups = typeof privateMutualGroupMap[sessionId] === 'number' + ? Math.max(0, Math.floor(privateMutualGroupMap[sessionId])) + : 0 + } + result[sessionId] = stats + } catch { + result[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + } + }) + + return result + } + + private async getOrComputeSessionExportStats( + sessionId: string, + includeRelations: boolean, + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false + ): Promise { + if (preferAccurateSpecialTypes) { + return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, true) + } + + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + + if (!includeRelations) { + const pendingFull = this.sessionStatsPendingFull.get(scopedKey) + if (pendingFull) return pendingFull + const pendingBasic = this.sessionStatsPendingBasic.get(scopedKey) + if (pendingBasic) return pendingBasic + } else { + const pendingFull = this.sessionStatsPendingFull.get(scopedKey) + if (pendingFull) return pendingFull + } + + const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic + const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false) + targetMap.set(scopedKey, pending) + try { + return await pending + } finally { + targetMap.delete(scopedKey) + } + } + /** * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 */ @@ -2871,8 +4603,8 @@ class ChatService { private shouldKeepSession(username: string): boolean { if (!username) return false const lowered = username.toLowerCase() - // placeholder_foldgroup 是折叠群入口,需要保留 - if (lowered.includes('@placeholder') && !lowered.includes('foldgroup')) return false + // 排除所有 placeholder 会话(包括折叠群) + if (lowered.includes('@placeholder')) return false if (username.startsWith('gh_')) return false const excludeList = [ @@ -2899,11 +4631,27 @@ class ChatService { if (!connectResult.success) return null const result = await wcdbService.getContact(username) if (!result.success || !result.contact) return null + const contact = result.contact as Record + let alias = String(contact.alias || contact.Alias || '') + // 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 || '') + } + } catch { + // 兜底失败不影响主流程 + } + } return { - username: result.contact.username || username, - alias: result.contact.alias || '', - remark: result.contact.remark || '', - nickName: result.contact.nickName || '' + username: String(contact.username || contact.user_name || contact.userName || username || ''), + alias, + remark: String(contact.remark || contact.Remark || ''), + // 兼容不同表结构字段,避免 nick_name 丢失导致侧边栏退化到 wxid。 + nickName: String(contact.nickName || contact.nick_name || contact.nickname || contact.NickName || '') } } catch { return null @@ -2921,7 +4669,7 @@ class ChatService { if (!connectResult.success) return null const cached = this.avatarCache.get(username) // 检查缓存是否有效,且头像不是错误的 hex 格式 - const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8') + const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { return { avatarUrl: cached.avatarUrl, displayName: cached.displayName } } @@ -3093,6 +4841,16 @@ class ChatService { this.voiceTranscriptPending.clear() } + if (includeMessages || includeContacts) { + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + this.sessionStatsCacheService.clearAll() + this.groupMyMessageCountMemoryCache.clear() + this.groupMyMessageCountCacheService.clearAll() + } + for (const state of this.hardlinkCache.values()) { try { state.db?.close() @@ -3311,20 +5069,9 @@ class ChatService { /** * 获取会话详情信息 */ - async getSessionDetail(sessionId: string): Promise<{ + async getSessionDetailFast(sessionId: string): Promise<{ success: boolean - detail?: { - wxid: string - displayName: string - remark?: string - nickName?: string - alias?: string - avatarUrl?: string - messageCount: number - firstMessageTime?: number - latestMessageTime?: number - messageTables: { dbName: string; tableName: string; count: number }[] - } + detail?: SessionDetailFast error?: string }> { try { @@ -3332,53 +5079,164 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + this.refreshSessionMessageCountCacheScope() - let displayName = sessionId + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailFastCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailFastCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + let displayName = normalizedSessionId let remark: string | undefined let nickName: string | undefined let alias: string | undefined let avatarUrl: string | undefined - - const contactResult = await wcdbService.getContact(sessionId) - if (contactResult.success && contactResult.contact) { - remark = contactResult.contact.remark || undefined - nickName = contactResult.contact.nickName || undefined - alias = contactResult.contact.alias || undefined - displayName = remark || nickName || alias || sessionId - } - const avatarResult = await wcdbService.getAvatarUrls([sessionId]) - if (avatarResult.success && avatarResult.map) { - avatarUrl = avatarResult.map[sessionId] - } - - const countResult = await wcdbService.getMessageCount(sessionId) - const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0 - - let firstMessageTime: number | undefined - let latestMessageTime: number | undefined - - const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0) - if (earliestCursor.success && earliestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + const cachedContact = this.avatarCache.get(normalizedSessionId) + if (cachedContact) { + displayName = cachedContact.displayName || normalizedSessionId + if (this.isValidAvatarUrl(cachedContact.avatarUrl)) { + avatarUrl = cachedContact.avatarUrl } - await wcdbService.closeMessageCursor(earliestCursor.cursor) } - const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0) - if (latestCursor.success && latestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + const contactPromise = wcdbService.getContact(normalizedSessionId) + const avatarPromise = avatarUrl + ? Promise.resolve({ success: true, map: { [normalizedSessionId]: avatarUrl } }) + : wcdbService.getAvatarUrls([normalizedSessionId]) + + let messageCount: number | undefined + const cachedCount = this.sessionMessageCountCache.get(normalizedSessionId) + if (cachedCount && now - cachedCount.updatedAt <= this.sessionMessageCountCacheTtlMs) { + messageCount = cachedCount.count + } else { + const hintCount = this.sessionMessageCountHintCache.get(normalizedSessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + messageCount = Math.floor(hintCount) + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: now + }) } - await wcdbService.closeMessageCursor(latestCursor.cursor) } + const messageCountPromise = Number.isFinite(messageCount) + ? Promise.resolve<{ success: boolean; count?: number }>({ + success: true, + count: Math.max(0, Math.floor(messageCount as number)) + }) + : wcdbService.getMessageCount(normalizedSessionId) + + const [contactResult, avatarResult, messageCountResult] = await Promise.allSettled([ + contactPromise, + avatarPromise, + messageCountPromise + ]) + + if (contactResult.status === 'fulfilled' && contactResult.value.success && contactResult.value.contact) { + remark = contactResult.value.contact.remark || undefined + nickName = contactResult.value.contact.nickName || undefined + alias = contactResult.value.contact.alias || undefined + displayName = remark || nickName || alias || displayName + } + + if (avatarResult.status === 'fulfilled' && avatarResult.value.success && avatarResult.value.map) { + const avatarCandidate = avatarResult.value.map[normalizedSessionId] + if (this.isValidAvatarUrl(avatarCandidate)) { + avatarUrl = avatarCandidate + } + } + + if (!Number.isFinite(messageCount)) { + messageCount = messageCountResult.status === 'fulfilled' && + messageCountResult.value.success && + Number.isFinite(messageCountResult.value.count) + ? Math.max(0, Math.floor(messageCountResult.value.count || 0)) + : 0 + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: Date.now() + }) + } + + const detail: SessionDetailFast = { + wxid: normalizedSessionId, + displayName, + remark, + nickName, + alias, + avatarUrl, + messageCount: Math.max(0, Math.floor(messageCount || 0)) + } + + this.sessionDetailFastCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + + return { success: true, detail } + } catch (e) { + console.error('ChatService: 获取会话详情快速信息失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetailExtra(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetailExtra + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailExtraCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailExtraCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + const [tableStatsResult, statsResult] = await Promise.allSettled([ + wcdbService.getMessageTableStats(normalizedSessionId), + (async (): Promise => { + const cachedStats = this.getSessionStatsCacheEntry(normalizedSessionId) + if (cachedStats && this.supportsRequestedRelation(cachedStats.entry, false)) { + return this.fromSessionStatsCacheStats(cachedStats.entry.stats) + } + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + const stats = await this.getOrComputeSessionExportStats(normalizedSessionId, false, selfIdentitySet) + this.setSessionStatsCacheEntry(normalizedSessionId, stats, false) + return stats + })() + ]) + + const statsSnapshot = statsResult.status === 'fulfilled' + ? statsResult.value + : null + const firstMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.firstTimestamp) + ? Math.max(0, Math.floor(statsSnapshot.firstTimestamp as number)) + : undefined + const latestMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.lastTimestamp) + ? Math.max(0, Math.floor(statsSnapshot.lastTimestamp as number)) + : undefined + const messageTables: { dbName: string; tableName: string; count: number }[] = [] - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (tableStats.success && tableStats.tables) { - for (const row of tableStats.tables) { + if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { + for (const row of tableStatsResult.value.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', @@ -3387,26 +5245,254 @@ class ChatService { } } + const detail: SessionDetailExtra = { + firstMessageTime, + latestMessageTime, + messageTables + } + + this.sessionDetailExtraCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + return { success: true, - detail: { - wxid: sessionId, - displayName, - remark, - nickName, - alias, - avatarUrl, - messageCount: totalMessageCount, - firstMessageTime, - latestMessageTime, - messageTables - } + detail } + } catch (e) { + console.error('ChatService: 获取会话详情补充统计失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetail(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetail + error?: string + }> { + try { + const fastResult = await this.getSessionDetailFast(sessionId) + if (!fastResult.success || !fastResult.detail) { + return { success: false, error: fastResult.error || '获取会话详情失败' } + } + + const extraResult = await this.getSessionDetailExtra(sessionId) + const detail: SessionDetail = { + ...fastResult.detail, + firstMessageTime: extraResult.success ? extraResult.detail?.firstMessageTime : undefined, + latestMessageTime: extraResult.success ? extraResult.detail?.latestMessageTime : undefined, + messageTables: extraResult.success && extraResult.detail?.messageTables + ? extraResult.detail.messageTables + : [] + } + + return { success: true, detail } } catch (e) { console.error('ChatService: 获取会话详情失败:', e) return { success: false, error: String(e) } } } + + async getGroupMyMessageCountHint(chatroomId: string): Promise<{ + success: boolean + count?: number + updatedAt?: number + source?: 'memory' | 'disk' + error?: string + }> { + try { + this.refreshSessionMessageCountCacheScope() + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { + return { success: false, error: '群聊ID无效' } + } + + const cached = this.getGroupMyMessageCountHintEntry(normalizedChatroomId) + if (!cached) return { success: true } + return { + success: true, + count: cached.entry.messageCount, + updatedAt: cached.entry.updatedAt, + source: cached.source + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async setGroupMyMessageCountHint( + chatroomId: string, + messageCount: number, + updatedAt?: number + ): Promise<{ success: boolean; updatedAt?: number; error?: string }> { + try { + this.refreshSessionMessageCountCacheScope() + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { + return { success: false, error: '群聊ID无效' } + } + const savedAt = this.setGroupMyMessageCountHintEntry(normalizedChatroomId, messageCount, updatedAt) + return { success: true, updatedAt: savedAt } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{ + success: boolean + data?: Record + cache?: Record + needsRefresh?: string[] + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const includeRelations = options.includeRelations ?? true + const forceRefresh = options.forceRefresh === true + const allowStaleCache = options.allowStaleCache === true + const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, data: {}, cache: {} } + } + + const resultMap: Record = {} + const cacheMeta: Record = {} + const needsRefreshSet = new Set() + const pendingSessionIds: string[] = [] + const now = Date.now() + + for (const sessionId of normalizedSessionIds) { + const groupMyMessagesHint = sessionId.endsWith('@chatroom') + ? this.getGroupMyMessageCountHintEntry(sessionId) + : null + const cachedResult = this.getSessionStatsCacheEntry(sessionId) + if (!forceRefresh && !preferAccurateSpecialTypes) { + if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { + const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs + if (!stale || allowStaleCache) { + resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) + if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { + resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount + } + cacheMeta[sessionId] = { + updatedAt: cachedResult.entry.updatedAt, + stale, + includeRelations: cachedResult.entry.includeRelations, + source: cachedResult.source + } + if (stale) { + needsRefreshSet.add(sessionId) + } + continue + } + } + // allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。 + if (allowStaleCache && cachedResult) { + needsRefreshSet.add(sessionId) + continue + } + } + pendingSessionIds.push(sessionId) + } + + if (pendingSessionIds.length > 0) { + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + let usedBatchedCompute = false + if (pendingSessionIds.length === 1) { + const sessionId = pendingSessionIds[0] + try { + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes) + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + usedBatchedCompute = true + } catch { + usedBatchedCompute = false + } + } else { + try { + const batchedStatsMap = await this.computeSessionExportStatsBatch( + pendingSessionIds, + includeRelations, + selfIdentitySet, + preferAccurateSpecialTypes + ) + for (const sessionId of pendingSessionIds) { + const stats = batchedStatsMap[sessionId] + if (!stats) continue + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + } + usedBatchedCompute = true + } catch { + usedBatchedCompute = false + } + } + + if (!usedBatchedCompute) { + await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => { + try { + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes) + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + } catch { + resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + } + }) + } + } + + const response: { + success: boolean + data?: Record + cache?: Record + needsRefresh?: string[] + } = { + success: true, + data: resultMap, + cache: cacheMeta + } + if (needsRefreshSet.size > 0) { + response.needsRefresh = Array.from(needsRefreshSet) + } + return response + } catch (e) { + console.error('ChatService: 获取导出会话统计失败:', e) + return { success: false, error: String(e) } + } + } /** * 获取图片数据(解密后的) */ @@ -4220,6 +6306,36 @@ class ChatService { return this.voiceTranscriptCache.has(cacheKey) } + /** + * 批量统计转写缓存命中数(按会话维度)。 + * 仅基于本地 transcripts cache key 统计,用于导出前快速预估。 + */ + getCachedVoiceTranscriptCountMap(sessionIds: string[]): Record { + this.loadTranscriptCacheIfNeeded() + const normalizedIds = Array.from( + new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) + ) + const targetSet = new Set(normalizedIds) + const countMap: Record = {} + for (const sessionId of normalizedIds) { + countMap[sessionId] = 0 + } + if (targetSet.size === 0) return countMap + + 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() + if (!sessionId || !targetSet.has(sessionId)) continue + countMap[sessionId] = (countMap[sessionId] || 0) + 1 + } + + return countMap + } + /** * 获取某会话的所有语音消息(localType=34),用于批量转写 */ @@ -4375,6 +6491,66 @@ class ChatService { } } + async getMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + 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: '查询每日消息数失败' } + } + + console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`) + return { success: true, counts } + } catch (error) { + console.error('[ChatService] 获取每日消息数失败:', error) + return { success: false, error: String(error) } + } + } + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { // 1. 尝试从缓存获取会话表信息 diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts new file mode 100644 index 0000000..f1a1c02 --- /dev/null +++ b/electron/services/cloudControlService.ts @@ -0,0 +1,68 @@ +import { app } from 'electron' +import { wcdbService } from './wcdbService' + +interface UsageStats { + appVersion: string + platform: string + deviceId: string + timestamp: number + online: boolean + pages: string[] +} + +class CloudControlService { + private deviceId: string = '' + private timer: NodeJS.Timeout | null = null + private pages: Set = new Set() + + async init() { + this.deviceId = this.getDeviceId() + await wcdbService.cloudInit(300) + await this.reportOnline() + + this.timer = setInterval(() => { + this.reportOnline() + }, 300000) + } + + private getDeviceId(): string { + const crypto = require('crypto') + const os = require('os') + const machineId = os.hostname() + os.platform() + os.arch() + return crypto.createHash('md5').update(machineId).digest('hex') + } + + private async reportOnline() { + const data: UsageStats = { + appVersion: app.getVersion(), + platform: process.platform, + deviceId: this.deviceId, + timestamp: Date.now(), + online: true, + pages: Array.from(this.pages) + } + + await wcdbService.cloudReport(JSON.stringify(data)) + this.pages.clear() + } + + recordPage(pageName: string) { + this.pages.add(pageName) + } + + stop() { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + wcdbService.cloudStop() + } + + async getLogs() { + return wcdbService.getLogs() + } +} + +export const cloudControlService = new CloudControlService() + + diff --git a/electron/services/config.ts b/electron/services/config.ts index 41f2b9d..6ec8270 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -105,7 +105,7 @@ export class ConfigService { whisperDownloadSource: 'tsinghua', autoTranscribeVoice: false, transcribeLanguages: ['zh'], - exportDefaultConcurrency: 2, + exportDefaultConcurrency: 4, analyticsExcludedUsernames: [], authEnabled: false, authPassword: '', @@ -671,4 +671,4 @@ export class ConfigService { this.unlockedKeys.clear() this.unlockPassword = null } -} \ No newline at end of file +} diff --git a/electron/services/exportCardDiagnosticsService.ts b/electron/services/exportCardDiagnosticsService.ts new file mode 100644 index 0000000..37768a0 --- /dev/null +++ b/electron/services/exportCardDiagnosticsService.ts @@ -0,0 +1,354 @@ +import { mkdir, writeFile } from 'fs/promises' +import { basename, dirname, extname, join } from 'path' + +export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker' +export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error' +export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout' + +export interface ExportCardDiagLogEntry { + id: string + ts: number + source: ExportCardDiagSource + level: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +interface ActiveStepState { + key: string + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + startedAt: number + lastUpdatedAt: number + message?: string +} + +interface StepStartInput { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + level?: ExportCardDiagLevel + message?: string + data?: Record +} + +interface StepEndInput { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + status?: Extract + level?: ExportCardDiagLevel + message?: string + data?: Record + durationMs?: number +} + +interface LogInput { + ts?: number + source: ExportCardDiagSource + level?: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +export interface ExportCardDiagSnapshot { + logs: ExportCardDiagLogEntry[] + activeSteps: Array<{ + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + elapsedMs: number + stallMs: number + startedAt: number + lastUpdatedAt: number + message?: string + }> + summary: { + totalLogs: number + activeStepCount: number + errorCount: number + warnCount: number + timeoutCount: number + lastUpdatedAt: number + } +} + +export class ExportCardDiagnosticsService { + private readonly maxLogs = 6000 + private logs: ExportCardDiagLogEntry[] = [] + private activeSteps = new Map() + private seq = 0 + + private nextId(ts: number): string { + this.seq += 1 + return `export-card-diag-${ts}-${this.seq}` + } + + private trimLogs() { + if (this.logs.length <= this.maxLogs) return + const drop = this.logs.length - this.maxLogs + this.logs.splice(0, drop) + } + + log(input: LogInput): ExportCardDiagLogEntry { + const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now() + const entry: ExportCardDiagLogEntry = { + id: this.nextId(ts), + ts, + source: input.source, + level: input.level || 'info', + message: input.message, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined, + data: input.data + } + + this.logs.push(entry) + this.trimLogs() + + if (entry.traceId && entry.stepId && entry.stepName) { + const key = `${entry.traceId}::${entry.stepId}` + if (entry.status === 'running') { + const previous = this.activeSteps.get(key) + this.activeSteps.set(key, { + key, + traceId: entry.traceId, + stepId: entry.stepId, + stepName: entry.stepName, + source: entry.source, + startedAt: previous?.startedAt || entry.ts, + lastUpdatedAt: entry.ts, + message: entry.message + }) + } else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') { + this.activeSteps.delete(key) + } + } + + return entry + } + + stepStart(input: StepStartInput): ExportCardDiagLogEntry { + return this.log({ + source: input.source, + level: input.level || 'info', + message: input.message || `${input.stepName} 开始`, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: 'running', + data: input.data + }) + } + + stepEnd(input: StepEndInput): ExportCardDiagLogEntry { + return this.log({ + source: input.source, + level: input.level || (input.status === 'done' ? 'info' : 'warn'), + message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status || 'done', + durationMs: input.durationMs, + data: input.data + }) + } + + clear() { + this.logs = [] + this.activeSteps.clear() + } + + snapshot(limit = 1200): ExportCardDiagSnapshot { + const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200 + const logs = this.logs.slice(-capped) + const now = Date.now() + + const activeSteps = Array.from(this.activeSteps.values()) + .map(step => ({ + traceId: step.traceId, + stepId: step.stepId, + stepName: step.stepName, + source: step.source, + startedAt: step.startedAt, + lastUpdatedAt: step.lastUpdatedAt, + elapsedMs: Math.max(0, now - step.startedAt), + stallMs: Math.max(0, now - step.lastUpdatedAt), + message: step.message + })) + .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt) + + let errorCount = 0 + let warnCount = 0 + let timeoutCount = 0 + for (const item of logs) { + if (item.level === 'error') errorCount += 1 + if (item.level === 'warn') warnCount += 1 + if (item.status === 'timeout') timeoutCount += 1 + } + + return { + logs, + activeSteps, + summary: { + totalLogs: this.logs.length, + activeStepCount: activeSteps.length, + errorCount, + warnCount, + timeoutCount, + lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0 + } + } + } + + private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] { + const result: ExportCardDiagLogEntry[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') continue + const row = item as Record + const tsRaw = row.ts ?? row.timestamp + const tsNum = Number(tsRaw) + const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now() + + const sourceRaw = String(row.source || 'frontend') + const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker' + ? sourceRaw + : 'frontend' + const levelRaw = String(row.level || 'info') + const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error' + ? levelRaw + : 'info' + + const statusRaw = String(row.status || '') + const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout' + ? statusRaw + : undefined + + const durationRaw = Number(row.durationMs) + result.push({ + id: String(row.id || this.nextId(ts)), + ts, + source, + level, + message: String(row.message || ''), + traceId: typeof row.traceId === 'string' ? row.traceId : undefined, + stepId: typeof row.stepId === 'string' ? row.stepId : undefined, + stepName: typeof row.stepName === 'string' ? row.stepName : undefined, + status, + durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined, + data: row.data && typeof row.data === 'object' ? row.data as Record : undefined + }) + } + return result + } + + private serializeLogEntry(log: ExportCardDiagLogEntry): string { + return JSON.stringify(log) + } + + private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string { + const total = logs.length + let errorCount = 0 + let warnCount = 0 + let timeoutCount = 0 + let frontendCount = 0 + let backendCount = 0 + let mainCount = 0 + let workerCount = 0 + + for (const item of logs) { + if (item.level === 'error') errorCount += 1 + if (item.level === 'warn') warnCount += 1 + if (item.status === 'timeout') timeoutCount += 1 + if (item.source === 'frontend') frontendCount += 1 + if (item.source === 'backend') backendCount += 1 + if (item.source === 'main') mainCount += 1 + if (item.source === 'worker') workerCount += 1 + } + + const lines: string[] = [] + lines.push('WeFlow 导出卡片诊断摘要') + lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`) + lines.push(`日志总数: ${total}`) + lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`) + lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`) + lines.push(`当前活跃步骤: ${activeSteps.length}`) + + if (activeSteps.length > 0) { + lines.push('') + lines.push('活跃步骤:') + for (const step of activeSteps.slice(0, 12)) { + lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`) + } + } + + const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12) + if (latestErrors.length > 0) { + lines.push('') + lines.push('最近异常:') + for (const item of latestErrors) { + lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`) + } + } + + return lines.join('\n') + } + + async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{ + success: boolean + filePath?: string + summaryPath?: string + count?: number + error?: string + }> { + try { + const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : []) + const merged = [...this.logs, ...normalizedFrontend] + .sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id)) + + const lines = merged.map(item => this.serializeLogEntry(item)).join('\n') + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8') + + const ext = extname(filePath) + const baseName = ext ? basename(filePath, ext) : basename(filePath) + const summaryPath = join(dirname(filePath), `${baseName}.txt`) + const snapshot = this.snapshot(1500) + const summaryText = this.buildSummaryText(merged, snapshot.activeSteps) + await writeFile(summaryPath, summaryText, 'utf8') + + return { + success: true, + filePath, + summaryPath, + count: merged.length + } + } catch (error) { + return { + success: false, + error: String(error) + } + } + } +} + +export const exportCardDiagnosticsService = new ExportCardDiagnosticsService() diff --git a/electron/services/exportContentStatsCacheService.ts b/electron/services/exportContentStatsCacheService.ts new file mode 100644 index 0000000..ee8fd5f --- /dev/null +++ b/electron/services/exportContentStatsCacheService.ts @@ -0,0 +1,229 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_SCOPE_ENTRIES = 12 +const MAX_SESSION_ENTRIES_PER_SCOPE = 6000 + +export interface ExportContentSessionStatsEntry { + updatedAt: number + hasAny: boolean + hasVoice: boolean + hasImage: boolean + hasVideo: boolean + hasEmoji: boolean + mediaReady: boolean +} + +export interface ExportContentScopeStatsEntry { + updatedAt: number + sessions: Record +} + +interface ExportContentStatsStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function toBoolean(value: unknown, fallback = false): boolean { + if (typeof value === 'boolean') return value + return fallback +} + +function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + if (updatedAt === undefined) return null + return { + updatedAt, + hasAny: toBoolean(source.hasAny, false), + hasVoice: toBoolean(source.hasVoice, false), + hasImage: toBoolean(source.hasImage, false), + hasVideo: toBoolean(source.hasVideo, false), + hasEmoji: toBoolean(source.hasEmoji, false), + mediaReady: toBoolean(source.mediaReady, false) + } +} + +function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + if (updatedAt === undefined) return null + + const sessionsRaw = source.sessions + if (!sessionsRaw || typeof sessionsRaw !== 'object') { + return { + updatedAt, + sessions: {} + } + } + + const sessions: Record = {} + for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record)) { + const normalized = normalizeSessionStatsEntry(entryRaw) + if (!normalized) continue + sessions[sessionId] = normalized + } + + return { + updatedAt, + sessions + } +} + +function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry { + return { + updatedAt: scope.updatedAt, + sessions: Object.fromEntries( + Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }]) + ) + } +} + +export class ExportContentStatsCacheService { + private readonly cacheFilePath: string + private store: ExportContentStatsStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'export-content-stats.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record)) { + const normalizedScope = normalizeScopeStatsEntry(scopeRaw) + if (!normalizedScope) continue + scopes[scopeKey] = normalizedScope + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('ExportContentStatsCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined { + if (!scopeKey) return undefined + const rawScope = this.store.scopes[scopeKey] + if (!rawScope) return undefined + const normalizedScope = normalizeScopeStatsEntry(rawScope) + if (!normalizedScope) { + delete this.store.scopes[scopeKey] + this.persist() + return undefined + } + this.store.scopes[scopeKey] = normalizedScope + return cloneScope(normalizedScope) + } + + setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void { + if (!scopeKey) return + const normalized = normalizeScopeStatsEntry(scope) + if (!normalized) return + this.store.scopes[scopeKey] = normalized + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + deleteSession(scopeKey: string, sessionId: string): void { + if (!scopeKey || !sessionId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(sessionId in scope.sessions)) return + delete scope.sessions[sessionId] + if (Object.keys(scope.sessions).length === 0) { + delete this.store.scopes[scopeKey] + } else { + scope.updatedAt = Date.now() + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('ExportContentStatsCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + + const entries = Object.entries(scope.sessions) + if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return + + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + + scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) + } + + private persist(): void { + try { + this.ensureCacheDir() + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('ExportContentStatsCacheService: 持久化缓存失败', error) + } + } +} diff --git a/electron/services/exportRecordService.ts b/electron/services/exportRecordService.ts new file mode 100644 index 0000000..23c82a9 --- /dev/null +++ b/electron/services/exportRecordService.ts @@ -0,0 +1,95 @@ +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +export interface ExportRecord { + exportTime: number + format: string + messageCount: number + sourceLatestMessageTimestamp?: number + outputPath?: string +} + +type RecordStore = Record + +class ExportRecordService { + private filePath: string | null = null + private loaded = false + private store: RecordStore = {} + + private resolveFilePath(): string { + if (this.filePath) return this.filePath + const userDataPath = app.getPath('userData') + fs.mkdirSync(userDataPath, { recursive: true }) + this.filePath = path.join(userDataPath, 'weflow-export-records.json') + return this.filePath + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + const filePath = this.resolveFilePath() + try { + if (!fs.existsSync(filePath)) return + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + this.store = parsed as RecordStore + } + } catch { + this.store = {} + } + } + + private persist(): void { + try { + const filePath = this.resolveFilePath() + fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8') + } catch { + // ignore persist errors to avoid blocking export flow + } + } + + getLatestRecord(sessionId: string, format: string): ExportRecord | null { + this.ensureLoaded() + const records = this.store[sessionId] + if (!records || records.length === 0) return null + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i] + if (record && record.format === format) return record + } + return null + } + + saveRecord( + sessionId: string, + format: string, + messageCount: number, + extra?: { + sourceLatestMessageTimestamp?: number + outputPath?: string + } + ): void { + this.ensureLoaded() + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + if (!this.store[normalizedSessionId]) { + this.store[normalizedSessionId] = [] + } + const list = this.store[normalizedSessionId] + list.push({ + exportTime: Date.now(), + format, + messageCount, + sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp, + outputPath: extra?.outputPath + }) + // keep the latest 30 records per session + if (list.length > 30) { + this.store[normalizedSessionId] = list.slice(-30) + } + this.persist() + } +} + +export const exportRecordService = new ExportRecordService() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index bfd50fe..5507bd6 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -11,6 +11,7 @@ import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' import { videoService } from './videoService' import { voiceTranscribeService } from './voiceTranscribeService' +import { exportRecordService } from './exportRecordService' import { EXPORT_HTML_STYLES } from './exportHtmlStyles' import { LRUCache } from '../utils/LRUCache.js' @@ -69,7 +70,8 @@ const MESSAGE_TYPE_MAP: Record = { } export interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -83,6 +85,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number } @@ -104,16 +107,65 @@ interface MediaExportItem { posterDataUrl?: string } +type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' +type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' + export interface ExportProgress { current: number total: number currentSession: string + currentSessionId?: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phaseProgress?: number phaseTotal?: number phaseLabel?: string } +interface ExportTaskControl { + shouldPause?: () => boolean + shouldStop?: () => boolean +} + +interface ExportStatsResult { + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> +} + +interface ExportStatsSessionSnapshot { + totalCount: number + voiceCount: number + imageCount: number + videoCount: number + emojiCount: number + cachedVoiceCount: number + lastTimestamp?: number +} + +interface ExportStatsCacheEntry { + createdAt: number + result: ExportStatsResult + sessions: Record +} + +interface ExportAggregatedSessionMetric { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + lastTimestamp?: number +} + +interface ExportAggregatedSessionStatsCacheEntry { + createdAt: number + data: Record +} + // 并发控制:限制同时执行的 Promise 数量 async function parallelLimit( items: T[], @@ -144,6 +196,12 @@ class ExportService { private contactCache: LRUCache private inlineEmojiCache: LRUCache private htmlStyleCache: string | null = null + private exportStatsCache = new Map() + private exportAggregatedSessionStatsCache = new Map() + private readonly exportStatsCacheTtlMs = 2 * 60 * 1000 + private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 + private readonly exportStatsCacheMaxEntries = 16 + private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' constructor() { this.configService = new ConfigService() @@ -152,12 +210,237 @@ class ExportService { this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情 } + private createStopError(): Error { + const error = new Error('导出任务已停止') + ;(error as Error & { code?: string }).code = this.STOP_ERROR_CODE + return error + } + + private normalizeSessionIds(sessionIds: string[]): string[] { + return Array.from( + new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) + ) + } + + private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string { + if (!dateRange) return 'all' + const start = Number.isFinite(dateRange.start) ? Math.max(0, Math.floor(dateRange.start)) : 0 + const end = Number.isFinite(dateRange.end) ? Math.max(0, Math.floor(dateRange.end)) : 0 + return `${start}-${end}` + } + + private buildExportStatsCacheKey( + sessionIds: string[], + options: Pick, + cleanedWxid?: string + ): string { + const normalizedIds = this.normalizeSessionIds(sessionIds).sort() + const senderToken = String(options.senderUsername || '').trim() + const dateToken = this.getExportStatsDateRangeToken(options.dateRange) + const dbPath = String(this.configService.get('dbPath') || '').trim() + const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim() + return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}` + } + + private cloneExportStatsResult(result: ExportStatsResult): ExportStatsResult { + return { + ...result, + sessions: result.sessions.map((item) => ({ ...item })) + } + } + + private pruneExportStatsCaches(): void { + const now = Date.now() + for (const [key, entry] of this.exportStatsCache.entries()) { + if (now - entry.createdAt > this.exportStatsCacheTtlMs) { + this.exportStatsCache.delete(key) + } + } + for (const [key, entry] of this.exportAggregatedSessionStatsCache.entries()) { + if (now - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { + this.exportAggregatedSessionStatsCache.delete(key) + } + } + } + + private getExportStatsCacheEntry(key: string): ExportStatsCacheEntry | null { + this.pruneExportStatsCaches() + const entry = this.exportStatsCache.get(key) + if (!entry) return null + if (Date.now() - entry.createdAt > this.exportStatsCacheTtlMs) { + this.exportStatsCache.delete(key) + return null + } + return entry + } + + private setExportStatsCacheEntry(key: string, entry: ExportStatsCacheEntry): void { + this.pruneExportStatsCaches() + this.exportStatsCache.set(key, entry) + if (this.exportStatsCache.size <= this.exportStatsCacheMaxEntries) return + const staleKeys = Array.from(this.exportStatsCache.entries()) + .sort((a, b) => a[1].createdAt - b[1].createdAt) + .slice(0, Math.max(0, this.exportStatsCache.size - this.exportStatsCacheMaxEntries)) + .map(([cacheKey]) => cacheKey) + for (const staleKey of staleKeys) { + this.exportStatsCache.delete(staleKey) + } + } + + private getAggregatedSessionStatsCache(key: string): Record | null { + this.pruneExportStatsCaches() + const entry = this.exportAggregatedSessionStatsCache.get(key) + if (!entry) return null + if (Date.now() - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { + this.exportAggregatedSessionStatsCache.delete(key) + return null + } + return entry.data + } + + private setAggregatedSessionStatsCache( + key: string, + data: Record + ): void { + this.pruneExportStatsCaches() + this.exportAggregatedSessionStatsCache.set(key, { + createdAt: Date.now(), + data + }) + if (this.exportAggregatedSessionStatsCache.size <= this.exportStatsCacheMaxEntries) return + const staleKeys = Array.from(this.exportAggregatedSessionStatsCache.entries()) + .sort((a, b) => a[1].createdAt - b[1].createdAt) + .slice(0, Math.max(0, this.exportAggregatedSessionStatsCache.size - this.exportStatsCacheMaxEntries)) + .map(([cacheKey]) => cacheKey) + for (const staleKey of staleKeys) { + this.exportAggregatedSessionStatsCache.delete(staleKey) + } + } + + private isStopError(error: unknown): boolean { + if (!error) return false + if (typeof error === 'string') { + return error.includes(this.STOP_ERROR_CODE) || error.includes('导出任务已停止') + } + if (error instanceof Error) { + const code = (error as Error & { code?: string }).code + return code === this.STOP_ERROR_CODE || error.message.includes(this.STOP_ERROR_CODE) || error.message.includes('导出任务已停止') + } + return false + } + + private throwIfStopRequested(control?: ExportTaskControl): void { + if (control?.shouldStop?.()) { + throw this.createStopError() + } + } + private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { if (typeof value !== 'number' || !Number.isFinite(value)) return fallback const raw = Math.floor(value) return Math.max(1, Math.min(raw, max)) } + private isMediaExportEnabled(options: ExportOptions): boolean { + return options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + } + + private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { + if (!dateRange) return true + const start = Number.isFinite(dateRange.start) ? dateRange.start : 0 + const end = Number.isFinite(dateRange.end) ? dateRange.end : 0 + return start <= 0 && end <= 0 + } + + private shouldUseFastTextCollection(options: ExportOptions): boolean { + // 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用 + return !this.isMediaExportEnabled(options) + } + + private getMediaContentType(options: ExportOptions): MediaContentType | null { + const value = options.contentType + if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { + return value + } + return null + } + + private isMediaContentBatchExport(options: ExportOptions): boolean { + return this.getMediaContentType(options) !== null + } + + private getTargetMediaLocalTypes(options: ExportOptions): Set { + const mediaContentType = this.getMediaContentType(options) + if (mediaContentType === 'voice') return new Set([34]) + if (mediaContentType === 'image') return new Set([3]) + if (mediaContentType === 'video') return new Set([43]) + if (mediaContentType === 'emoji') return new Set([47]) + + const selected = new Set() + if (options.exportImages) selected.add(3) + if (options.exportVoices) selected.add(34) + if (options.exportVideos) selected.add(43) + if (options.exportEmojis) selected.add(47) + return selected + } + + private resolveCollectMode(options: ExportOptions): MessageCollectMode { + if (this.isMediaContentBatchExport(options)) { + return 'media-fast' + } + return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full' + } + + private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set } { + const mode = this.resolveCollectMode(options) + if (mode === 'media-fast') { + const targetMediaTypes = this.getTargetMediaLocalTypes(options) + if (targetMediaTypes.size > 0) { + return { mode, targetMediaTypes } + } + } + return { mode } + } + + private createCollectProgressReporter( + sessionName: string, + onProgress?: (progress: ExportProgress) => void, + progressCurrent = 5 + ): ((payload: { fetched: number }) => void) | undefined { + if (!onProgress) return undefined + let lastReportAt = 0 + return ({ fetched }) => { + const now = Date.now() + if (now - lastReportAt < 350) return + lastReportAt = now + onProgress({ + current: progressCurrent, + total: 100, + currentSession: sessionName, + phase: 'preparing', + phaseLabel: `收集消息 ${fetched.toLocaleString()} 条` + }) + } + } + + private shouldDecodeMessageContentInFastMode(localType: number): boolean { + // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 + if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) { + return false + } + return true + } + + private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set | null): boolean { + if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false + // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 + if (localType === 34) return false + // 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl + if (localType === 3 || localType === 43 || localType === 47) return true + return false + } + private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed @@ -204,6 +487,41 @@ class ExportService { return info } + private resolveSessionFilePrefix(sessionId: string, contact?: any): string { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return '私聊_' + if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' + if (normalizedSessionId.startsWith('gh_')) return '公众号_' + + const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type + const localType = Number.parseInt(String(rawLocalType ?? ''), 10) + const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim() + + if (Number.isFinite(localType) && localType === 0 && quanPin) { + return '曾经的好友_' + } + + return '私聊_' + } + + private async getSessionFilePrefix(sessionId: string): Promise { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return '私聊_' + if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' + if (normalizedSessionId.startsWith('gh_')) return '公众号_' + + try { + const contactResult = await wcdbService.getContact(normalizedSessionId) + if (contactResult.success && contactResult.contact) { + return this.resolveSessionFilePrefix(normalizedSessionId, contactResult.contact) + } + } catch { + // ignore and use default private prefix + } + + return '私聊_' + } + private async preloadContacts( usernames: Iterable, cache: Map, @@ -218,6 +536,22 @@ class ExportService { }) } + private async preloadContactInfos( + usernames: Iterable, + limit = 8 + ): Promise> { + const infoMap = new Map() + const unique = Array.from(new Set(Array.from(usernames).filter(Boolean))) + if (unique.length === 0) return infoMap + + await parallelLimit(unique, limit, async (username) => { + const info = await this.getContactInfo(username) + infoMap.set(username, info) + }) + + return infoMap + } + /** * 通过 contact.chat_room.ext_buffer 解析群昵称(纯 SQL) */ @@ -680,6 +1014,7 @@ class ExportService { case 49: { const title = this.extractXmlValue(content, 'title') const type = this.extractXmlValue(content, 'type') + const songName = this.extractXmlValue(content, 'songname') // 转账消息特殊处理 if (type === '2000') { @@ -692,6 +1027,7 @@ class ExportService { return transferPrefix } + if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' @@ -733,6 +1069,7 @@ class ExportService { } // 其他类型 + if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' @@ -1116,6 +1453,7 @@ class ExportService { if (xmlType) { switch (xmlType) { + case '3': return '音乐消息' case '87': return '群公告' case '2000': return '转账消息' case '5': return '链接消息' @@ -1308,6 +1646,295 @@ class ExportService { return content } + private extractFinderFeedDesc(content: string): string { + if (!content) return '' + const match = /([\s\S]*?)<\/desc>/i.exec(content) + if (!match) return '' + return match[1].replace(//g, '').trim() + } + + private extractAppMessageType(content: string): string { + if (!content) return '' + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) return typeMatch[1].trim() + } + return this.extractXmlValue(content, 'type') + } + + private looksLikeWxid(text: string): boolean { + if (!text) return false + const trimmed = text.trim().toLowerCase() + if (trimmed.startsWith('wxid_')) return true + return /^wx[a-z0-9_-]{4,}$/.test(trimmed) + } + + private sanitizeQuotedContent(content: string): string { + if (!content) return '' + let result = content + result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '') + result = result.replace(/^[\s::\-]+/, '') + result = result.replace(/[::]{2,}/g, ':') + result = result.replace(/^[\s::\-]+/, '') + result = result.replace(/\s+/g, ' ').trim() + return result + } + + private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return {} + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + let sender = this.extractXmlValue(referMsgXml, 'displayname') + if (sender && this.looksLikeWxid(sender)) { + sender = '' + } + + const referContent = this.extractXmlValue(referMsgXml, 'content') + const referType = this.extractXmlValue(referMsgXml, 'type') + let displayContent = referContent + + switch (referType) { + case '1': + displayContent = this.sanitizeQuotedContent(referContent) + break + case '3': + displayContent = '[图片]' + break + case '34': + displayContent = '[语音]' + break + case '43': + displayContent = '[视频]' + break + case '47': + displayContent = '[动画表情]' + break + case '49': + displayContent = '[链接]' + break + case '42': + displayContent = '[名片]' + break + case '48': + displayContent = '[位置]' + break + default: + if (!referContent || referContent.includes('wxid_')) { + displayContent = '[消息]' + } else { + displayContent = this.sanitizeQuotedContent(referContent) + } + } + + return { + content: displayContent || undefined, + sender: sender || undefined, + type: referType || undefined + } + } catch { + return {} + } + } + + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { + if (!content) return null + + const normalized = this.normalizeAppMessageContent(content) + const looksLikeAppMsg = + localType === 49 || + localType === 244813135921 || + normalized.includes('') + const hasReferMsg = normalized.includes('') + const xmlType = this.extractAppMessageType(normalized) + const isFinder = + xmlType === '51' || + normalized.includes('') || + normalized.includes('') + + if (!looksLikeAppMsg && !isFinder && !hasReferMsg) return null + + let appMsgKind: string | undefined + if (isFinder) { + appMsgKind = 'finder' + } else if (xmlType === '2001') { + appMsgKind = 'red-packet' + } else if (isMusic) { + appMsgKind = 'music' + } else if (xmlType === '33' || xmlType === '36') { + appMsgKind = 'miniapp' + } else if (xmlType === '6') { + appMsgKind = 'file' + } else if (xmlType === '19') { + appMsgKind = 'chat-record' + } else if (xmlType === '2000') { + appMsgKind = 'transfer' + } else if (xmlType === '87') { + appMsgKind = 'announcement' + } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { + appMsgKind = 'quote' + } else if (xmlType === '5' || xmlType === '49') { + appMsgKind = 'link' + } else if (looksLikeAppMsg) { + appMsgKind = 'card' + } + + const meta: Record = {} + if (xmlType) meta.appMsgType = xmlType + else if (appMsgKind === 'quote') meta.appMsgType = '57' + if (appMsgKind) meta.appMsgKind = appMsgKind + + if (appMsgKind === 'quote') { + const quoteInfo = this.parseQuoteMessage(normalized) + if (quoteInfo.content) meta.quotedContent = quoteInfo.content + if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender + if (quoteInfo.type) meta.quotedType = quoteInfo.type + } + + if (isMusic) { + const musicTitle = + this.extractXmlValue(normalized, 'songname') || + this.extractXmlValue(normalized, 'title') + const musicUrl = + this.extractXmlValue(normalized, 'musicurl') || + this.extractXmlValue(normalized, 'playurl') || + this.extractXmlValue(normalized, 'songalbumurl') + const musicDataUrl = + this.extractXmlValue(normalized, 'dataurl') || + this.extractXmlValue(normalized, 'lowurl') + const musicAlbumUrl = this.extractXmlValue(normalized, 'songalbumurl') + const musicCoverUrl = + this.extractXmlValue(normalized, 'thumburl') || + this.extractXmlValue(normalized, 'cdnthumburl') || + this.extractXmlValue(normalized, 'coverurl') || + this.extractXmlValue(normalized, 'cover') + const musicSinger = + this.extractXmlValue(normalized, 'singername') || + this.extractXmlValue(normalized, 'artist') || + this.extractXmlValue(normalized, 'albumartist') + const musicAppName = this.extractXmlValue(normalized, 'appname') + const musicSourceName = this.extractXmlValue(normalized, 'sourcename') + const durationRaw = + this.extractXmlValue(normalized, 'playlength') || + this.extractXmlValue(normalized, 'play_length') || + this.extractXmlValue(normalized, 'duration') + const musicDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null + + if (musicTitle) meta.musicTitle = musicTitle + if (musicUrl) meta.musicUrl = musicUrl + if (musicDataUrl) meta.musicDataUrl = musicDataUrl + if (musicAlbumUrl) meta.musicAlbumUrl = musicAlbumUrl + if (musicCoverUrl) meta.musicCoverUrl = musicCoverUrl + if (musicSinger) meta.musicSinger = musicSinger + if (musicAppName) meta.musicAppName = musicAppName + if (musicSourceName) meta.musicSourceName = musicSourceName + if (musicDuration != null) meta.musicDuration = musicDuration + } + + if (!isFinder) { + return Object.keys(meta).length > 0 ? meta : null + } + + const rawTitle = this.extractXmlValue(normalized, 'title') + const finderFeedDesc = this.extractFinderFeedDesc(normalized) + const finderTitle = (!rawTitle || rawTitle.includes('不支持')) ? finderFeedDesc : rawTitle + const finderDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc') + const finderUsername = + this.extractXmlValue(normalized, 'finderusername') || + this.extractXmlValue(normalized, 'finder_username') || + this.extractXmlValue(normalized, 'finderuser') + const finderNickname = + this.extractXmlValue(normalized, 'findernickname') || + this.extractXmlValue(normalized, 'finder_nickname') + const finderCoverUrl = + this.extractXmlValue(normalized, 'thumbUrl') || + this.extractXmlValue(normalized, 'coverUrl') || + this.extractXmlValue(normalized, 'thumburl') || + this.extractXmlValue(normalized, 'coverurl') + const finderAvatar = this.extractXmlValue(normalized, 'avatar') + const durationRaw = this.extractXmlValue(normalized, 'videoPlayDuration') || this.extractXmlValue(normalized, 'duration') + const finderDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null + const finderObjectId = + this.extractXmlValue(normalized, 'finderobjectid') || + this.extractXmlValue(normalized, 'finder_objectid') || + this.extractXmlValue(normalized, 'objectid') || + this.extractXmlValue(normalized, 'object_id') + const finderUrl = + this.extractXmlValue(normalized, 'url') || + this.extractXmlValue(normalized, 'shareurl') + + if (finderTitle) meta.finderTitle = finderTitle + if (finderDesc) meta.finderDesc = finderDesc + if (finderUsername) meta.finderUsername = finderUsername + if (finderNickname) meta.finderNickname = finderNickname + if (finderCoverUrl) meta.finderCoverUrl = finderCoverUrl + if (finderAvatar) meta.finderAvatar = finderAvatar + if (finderDuration != null) meta.finderDuration = finderDuration + if (finderObjectId) meta.finderObjectId = finderObjectId + if (finderUrl) meta.finderUrl = finderUrl + + return Object.keys(meta).length > 0 ? meta : null + } + + private extractArkmeContactCardMeta(content: string, localType: number): Record | null { + if (!content || localType !== 42) return null + + const normalized = this.normalizeAppMessageContent(content) + const readAttr = (attrName: string): string => + this.extractXmlAttribute(normalized, 'msg', attrName) || this.extractXmlValue(normalized, attrName) + + const contactCardWxid = + readAttr('username') || + readAttr('encryptusername') || + readAttr('encrypt_user_name') + const contactCardNickname = readAttr('nickname') + const contactCardAlias = readAttr('alias') + const contactCardRemark = readAttr('remark') + const contactCardProvince = readAttr('province') + const contactCardCity = readAttr('city') + const contactCardSignature = readAttr('sign') || readAttr('signature') + const contactCardAvatar = + readAttr('smallheadimgurl') || + readAttr('bigheadimgurl') || + readAttr('headimgurl') || + readAttr('avatar') + const sexRaw = readAttr('sex') + const contactCardGender = sexRaw ? parseInt(sexRaw, 10) : NaN + + const meta: Record = { + cardKind: 'contact-card' + } + if (contactCardWxid) meta.contactCardWxid = contactCardWxid + if (contactCardNickname) meta.contactCardNickname = contactCardNickname + if (contactCardAlias) meta.contactCardAlias = contactCardAlias + if (contactCardRemark) meta.contactCardRemark = contactCardRemark + if (contactCardProvince) meta.contactCardProvince = contactCardProvince + if (contactCardCity) meta.contactCardCity = contactCardCity + if (contactCardSignature) meta.contactCardSignature = contactCardSignature + if (contactCardAvatar) meta.contactCardAvatar = contactCardAvatar + if (Number.isFinite(contactCardGender) && contactCardGender >= 0) { + meta.contactCardGender = contactCardGender + } + + return Object.keys(meta).length > 0 ? meta : null + } + private getInlineEmojiDataUrl(name: string): string | null { if (!name) return null const cached = this.inlineEmojiCache.get(name) @@ -1568,7 +2195,10 @@ class ExportService { } const msgId = String(msg.localId) - const fileName = `voice_${msgId}.wav` + const safeSession = this.cleanAccountDirName(sessionId) + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 48) || 'session' + const fileName = `voice_${safeSession}_${msgId}.wav` const destPath = path.join(voicesDir, fileName) // 如果已存在则跳过 @@ -1765,6 +2395,46 @@ class ExportService { return tagMatch?.[1]?.toLowerCase() } + private extractLocationMeta(content: string, localType: number): { + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + } | null { + if (!content || localType !== 48) return null + + const normalized = this.normalizeAppMessageContent(content) + const rawLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') + const rawLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') + const locationPoiname = + this.extractXmlAttribute(normalized, 'location', 'poiname') || + this.extractXmlValue(normalized, 'poiname') || + this.extractXmlValue(normalized, 'poiName') + const locationLabel = + this.extractXmlAttribute(normalized, 'location', 'label') || + this.extractXmlValue(normalized, 'label') + + const meta: { + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + } = {} + + if (rawLat) { + const parsed = parseFloat(rawLat) + if (Number.isFinite(parsed)) meta.locationLat = parsed + } + if (rawLng) { + const parsed = parseFloat(rawLng) + if (Number.isFinite(parsed)) meta.locationLng = parsed + } + if (locationPoiname) meta.locationPoiname = locationPoiname + if (locationLabel) meta.locationLabel = locationLabel + + return Object.keys(meta).length > 0 ? meta : null + } + /** * 从 data URL 获取扩展名 */ @@ -1783,6 +2453,18 @@ class ExportService { const exportMediaEnabled = options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) const outputDir = path.dirname(outputPath) + const rawWriteLayout = this.configService.get('exportWriteLayout') + const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' + ? rawWriteLayout + : 'A' + // A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories. + if (writeLayout === 'A' && path.basename(outputDir) === 'texts') { + return { + exportMediaEnabled, + mediaRootDir: outputDir, + mediaRelativePrefix: '..' + } + } const outputBaseName = path.basename(outputPath, path.extname(outputPath)) const useSharedMediaLayout = options.sessionLayout === 'shared' const mediaRelativePrefix = useSharedMediaLayout @@ -1842,27 +2524,42 @@ class ExportService { sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null, - senderUsernameFilter?: string + senderUsernameFilter?: string, + collectMode: MessageCollectMode = 'full', + targetMediaTypes?: Set, + control?: ExportTaskControl, + onCollectProgress?: (payload: { fetched: number }) => void ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] const memberSet = new Map() const senderSet = new Set() let firstTime: number | null = null let lastTime: number | null = null + const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0 + ? targetMediaTypes + : null // 修复时间范围:0 表示不限制,而不是时间戳 0 const beginTime = dateRange?.start || 0 const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0 - console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`) - - const cursor = await wcdbService.openMessageCursor( - sessionId, - 500, - true, - beginTime, - endTime - ) + const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500 + this.throwIfStopRequested(control) + const cursor = collectMode === 'media-fast' + ? await wcdbService.openMessageCursorLite( + sessionId, + batchSize, + true, + beginTime, + endTime + ) + : await wcdbService.openMessageCursor( + sessionId, + batchSize, + true, + beginTime, + endTime + ) if (!cursor.success || !cursor.cursor) { console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`) return { rows, memberSet, firstTime, lastTime } @@ -1872,6 +2569,7 @@ class ExportService { let hasMore = true let batchCount = 0 while (hasMore) { + this.throwIfStopRequested(control) const batch = await wcdbService.fetchMessageBatch(cursor.cursor) batchCount++ @@ -1880,21 +2578,28 @@ class ExportService { break } - if (!batch.rows) { - console.warn(`[Export] 批次 ${batchCount} 无数据`) - break - } - - console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`) + if (!batch.rows) break + let rowIndex = 0 for (const row of batch.rows) { + if ((rowIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const createTime = parseInt(row.create_time || '0', 10) if (dateRange) { if (createTime < dateRange.start || createTime > dateRange.end) continue } - const content = this.decodeMessageContent(row.message_content, row.compress_content) const localType = parseInt(row.local_type || row.type || '1', 10) + if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { + continue + } + const shouldDecodeContent = collectMode === 'full' + || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) + || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter)) + const content = shouldDecodeContent + ? this.decodeMessageContent(row.message_content, row.compress_content) + : '' const senderUsername = row.sender_username || '' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSend = parseInt(isSendRaw, 10) === 1 @@ -1930,30 +2635,53 @@ class ExportService { } senderSet.add(actualSender) - // 提取媒体相关字段 + // 提取媒体相关字段(轻量模式下跳过) let imageMd5: string | undefined let imageDatName: string | undefined let emojiCdnUrl: string | undefined let emojiMd5: string | undefined let videoMd5: string | undefined + let locationLat: number | undefined + let locationLng: number | undefined + let locationPoiname: string | undefined + let locationLabel: string | undefined let chatRecordList: any[] | undefined - if (localType === 3 && content) { - // 图片消息 - imageMd5 = this.extractImageMd5(content) - imageDatName = this.extractImageDatName(content) - } else if (localType === 47 && content) { - // 动画表情 - emojiCdnUrl = this.extractEmojiUrl(content) - emojiMd5 = this.extractEmojiMd5(content) - } else if (localType === 43 && content) { - // 视频消息 - videoMd5 = this.extractVideoMd5(content) - } else if (localType === 49 && content) { - // 检查是否是聊天记录消息(type=19) - const xmlType = this.extractXmlValue(content, 'type') - if (xmlType === '19') { - chatRecordList = this.parseChatHistory(content) + if (localType === 48 && content) { + const locationMeta = this.extractLocationMeta(content, localType) + if (locationMeta) { + locationLat = locationMeta.locationLat + locationLng = locationMeta.locationLng + locationPoiname = locationMeta.locationPoiname + locationLabel = locationMeta.locationLabel + } + } + + if (collectMode === 'full' || collectMode === 'media-fast') { + // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 + imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined + imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined + emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined + emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined + videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined + + if (localType === 3 && content) { + // 图片消息 + imageMd5 = imageMd5 || this.extractImageMd5(content) + imageDatName = imageDatName || this.extractImageDatName(content) + } else if (localType === 47 && content) { + // 动画表情 + emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content) + emojiMd5 = emojiMd5 || this.extractEmojiMd5(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = videoMd5 || this.extractVideoMd5(content) + } else if (collectMode === 'full' && localType === 49 && content) { + // 检查是否是聊天记录消息(type=19) + const xmlType = this.extractXmlValue(content, 'type') + if (xmlType === '19') { + chatRecordList = this.parseChatHistory(content) + } } } @@ -1969,27 +2697,37 @@ class ExportService { emojiCdnUrl, emojiMd5, videoMd5, + locationLat, + locationLng, + locationPoiname, + locationLabel, chatRecordList }) if (firstTime === null || createTime < firstTime) firstTime = createTime if (lastTime === null || createTime > lastTime) lastTime = createTime } + onCollectProgress?.({ fetched: rows.length }) hasMore = batch.hasMore === true } - console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`) } catch (err) { + if (this.isStopError(err)) throw err console.error(`[Export] 收集消息异常:`, err) } finally { try { await wcdbService.closeMessageCursor(cursor.cursor) - console.log(`[Export] 游标已关闭`) } catch (err) { console.error(`[Export] 关闭游标失败:`, err) } } + this.throwIfStopRequested(control) + if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) { + await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter, control) + } + + this.throwIfStopRequested(control) if (senderSet.size > 0) { const usernames = Array.from(senderSet) const [nameResult, avatarResult] = await Promise.all([ @@ -2017,6 +2755,62 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + private async backfillMediaFieldsFromMessageDetail( + sessionId: string, + rows: any[], + targetMediaTypes: Set, + control?: ExportTaskControl + ): Promise { + const needsBackfill = rows.filter((msg) => { + if (!targetMediaTypes.has(msg.localType)) return false + if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName + if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl + if (msg.localType === 43) return !msg.videoMd5 + return false + }) + if (needsBackfill.length === 0) return + + const DETAIL_CONCURRENCY = 6 + await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) + const localId = Number(msg.localId || 0) + if (!Number.isFinite(localId) || localId <= 0) return + + try { + const detail = await wcdbService.getMessageById(sessionId, localId) + if (!detail.success || !detail.message) return + + const row = detail.message as any + const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? '' + const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? '' + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + + if (msg.localType === 3) { + const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) + const imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) + if (imageMd5) msg.imageMd5 = imageMd5 + if (imageDatName) msg.imageDatName = imageDatName + return + } + + if (msg.localType === 47) { + const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content) + const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content) + if (emojiMd5) msg.emojiMd5 = emojiMd5 + if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl + return + } + + if (msg.localType === 43) { + const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content) + if (videoMd5) msg.videoMd5 = videoMd5 + } + } catch (error) { + // 详情补取失败时保持降级导出(占位符),避免中断整批任务。 + } + }) + } + // 补齐群成员,避免只导出发言者导致头像缺失 private async mergeGroupMembers( chatroomId: string, @@ -2092,6 +2886,95 @@ class ExportService { } } + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + private extractGroupSenderCountMap(groupStats: any, sessionId: string): Map { + const senderCountMap = new Map() + if (!groupStats || typeof groupStats !== 'object') return senderCountMap + + const sessions = (groupStats as any).sessions + const sessionStats = sessions && typeof sessions === 'object' + ? (sessions[sessionId] || sessions[String(sessionId)] || null) + : null + const senderRaw = (sessionStats && typeof sessionStats === 'object' && (sessionStats as any).senders && typeof (sessionStats as any).senders === 'object') + ? (sessionStats as any).senders + : ((groupStats as any).senders && typeof (groupStats as any).senders === 'object' ? (groupStats as any).senders : {}) + const idMap = (groupStats as any).idMap && typeof (groupStats as any).idMap === 'object' + ? (groupStats as any).idMap + : ((sessionStats && typeof sessionStats === 'object' && (sessionStats as any).idMap && typeof (sessionStats as any).idMap === 'object') + ? (sessionStats as any).idMap + : {}) + + for (const [senderKey, rawCount] of Object.entries(senderRaw)) { + const countNumber = Number(rawCount) + if (!Number.isFinite(countNumber) || countNumber <= 0) continue + const count = Math.max(0, Math.floor(countNumber)) + const mapped = typeof (idMap as any)[senderKey] === 'string' ? String((idMap as any)[senderKey]).trim() : '' + const wxid = (mapped || String(senderKey || '').trim()) + if (!wxid) continue + senderCountMap.set(wxid, (senderCountMap.get(wxid) || 0) + count) + } + + return senderCountMap + } + + private sumSenderCountsByIdentity(senderCountMap: Map, wxid: string): number { + const target = String(wxid || '').trim() + if (!target) return 0 + let total = 0 + for (const [senderWxid, count] of senderCountMap.entries()) { + if (!Number.isFinite(count) || count <= 0) continue + if (this.isSameWxid(senderWxid, target)) { + total += count + } + } + return total + } + + private async queryFriendFlagMap(usernames: string[]): Promise> { + const result = new Map() + const unique = Array.from( + new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean)) + ) + 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) + } + } + + for (const username of unique) { + if (!result.has(username)) { + result.set(username, false) + } + } + + return result + } + private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { if (!avatarUrl) return null if (avatarUrl.startsWith('data:')) { @@ -2347,9 +3230,11 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -2365,7 +3250,18 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) const allMessages = collected.rows // 如果没有消息,不创建文件 @@ -2382,6 +3278,7 @@ class ExportService { } if (isGroup) { + this.throwIfStopRequested(control) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } @@ -2440,6 +3337,7 @@ class ExportService { const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { + this.throwIfStopRequested(control) const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -2484,6 +3382,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 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) voiceTranscribed++ @@ -2508,7 +3407,11 @@ class ExportService { }) const chatLabMessages: ChatLabMessage[] = [] + let messageIndex = 0 for (const msg of allMessages) { + if ((messageIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, accountName: msg.senderUsername, @@ -2701,13 +3604,17 @@ class ExportService { meta: chatLabExport.meta })) for (const member of chatLabExport.members) { + this.throwIfStopRequested(control) lines.push(JSON.stringify({ _type: 'member', ...member })) } for (const message of chatLabExport.messages) { + this.throwIfStopRequested(control) lines.push(JSON.stringify({ _type: 'message', ...message })) } + this.throwIfStopRequested(control) fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') } else { + this.throwIfStopRequested(control) fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') } @@ -2720,6 +3627,9 @@ class ExportService { return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -2731,9 +3641,11 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -2760,7 +3672,18 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -2776,11 +3699,19 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) await this.preloadContacts(senderUsernames, contactCache) + const senderInfoMap = await this.preloadContactInfos([ + ...Array.from(senderUsernames.values()), + cleanedMyWxid + ]) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) @@ -2811,6 +3742,7 @@ class ExportService { const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { + this.throwIfStopRequested(control) const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -2854,6 +3786,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 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) voiceTranscribed++ @@ -2890,8 +3823,21 @@ class ExportService { }) const allMessages: any[] = [] + const senderProfileMap = new Map() + const transferCandidates: Array<{ xml: string; messageRef: any }> = [] + let needSort = false + let lastCreateTime = Number.NEGATIVE_INFINITY + let messageIndex = 0 for (const msg of collected.rows) { - const senderInfo = await this.getContactInfo(msg.senderUsername) + if ((messageIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' } const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') const source = sourceMatch ? sourceMatch[0] : '' @@ -2915,28 +3861,11 @@ class ExportService { ) } - // 转账消息:追加 "谁转账给谁" 信息 - if (content && this.isTransferExportContent(content) && 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) { - content = this.appendTransferDesc(content, transferDesc) - } - } - // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername - const contact = await getContactCached(senderWxid) + const contact = senderWxid + ? (contactCache.get(senderWxid) ?? { success: false as const }) + : { success: false as const } const senderNickname = contact.success && contact.contact?.nickName ? contact.contact.nickName : (senderInfo.displayName || senderWxid) @@ -2951,6 +3880,15 @@ class ExportService { senderGroupNickname, options.displayNamePreference || 'remark' ) + const existingSenderProfile = senderProfileMap.get(senderWxid) + if (!existingSenderProfile) { + senderProfileMap.set(senderWxid, { + displayName: senderDisplayName, + nickname: senderNickname, + remark: senderRemark, + groupNickname: senderGroupNickname + }) + } const msgObj: any = { localId: allMessages.length + 1, @@ -2966,6 +3904,26 @@ class ExportService { senderAvatarKey: msg.senderUsername } + const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) + if (appMsgMeta) { + if (options.format === 'arkme-json') { + Object.assign(msgObj, appMsgMeta) + } else if (options.format === 'json' && appMsgMeta.appMsgKind === 'quote') { + Object.assign(msgObj, appMsgMeta) + } + } + + if (options.format === 'arkme-json') { + const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) + if (contactCardMeta) { + Object.assign(msgObj, contactCardMeta) + } + } + + if (content && this.isTransferExportContent(content) && msg.content) { + transferCandidates.push({ xml: msg.content, messageRef: msgObj }) + } + // 位置消息:附加结构化位置字段 if (msg.localType === 48) { if (msg.locationLat != null) msgObj.locationLat = msg.locationLat @@ -2975,9 +3933,51 @@ class ExportService { } allMessages.push(msgObj) + if (msg.createTime < lastCreateTime) needSort = true + lastCreateTime = msg.createTime } - allMessages.sort((a, b) => a.createTime - b.createTime) + if (transferCandidates.length > 0) { + const transferNameCache = new Map() + const transferNamePromiseCache = new Map>() + const resolveDisplayNameByUsername = async (username: string): Promise => { + if (!username) return username + const cachedName = transferNameCache.get(username) + if (cachedName) return cachedName + const pending = transferNamePromiseCache.get(username) + if (pending) return pending + const task = (async () => { + const contactResult = contactCache.get(username) ?? await getContactCached(username) + if (contactResult.success && contactResult.contact) { + return contactResult.contact.remark || contactResult.contact.nickName || contactResult.contact.alias || username + } + return username + })() + transferNamePromiseCache.set(username, task) + const resolved = await task + transferNamePromiseCache.delete(username) + transferNameCache.set(username, resolved) + return resolved + } + + const transferConcurrency = this.getClampedConcurrency(options.exportConcurrency, 4, 8) + await parallelLimit(transferCandidates, transferConcurrency, async (item) => { + this.throwIfStopRequested(control) + const transferDesc = await this.resolveTransferDesc( + item.xml, + cleanedMyWxid, + groupNicknamesMap, + resolveDisplayNameByUsername + ) + if (transferDesc && typeof item.messageRef.content === 'string') { + item.messageRef.content = this.appendTransferDesc(item.messageRef.content, transferDesc) + } + }) + } + + if (needSort) { + allMessages.sort((a, b) => a.createTime - b.createTime) + } onProgress?.({ current: 70, @@ -2986,10 +3986,8 @@ class ExportService { phase: 'writing' }) - const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup) - // 获取会话的昵称和备注信息 - const sessionContact = await getContactCached(sessionId) + const sessionContact = contactCache.get(sessionId) ?? await getContactCached(sessionId) const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionInfo.displayName @@ -3010,45 +4008,247 @@ class ExportService { ) const weflow = this.getWeflowHeader() - const detailedExport: any = { - weflow, - session: { - wxid: sessionId, - nickname: sessionNickname, - remark: sessionRemark, - displayName: sessionDisplayName, - type: isGroup ? '群聊' : '私聊', - lastTimestamp: collected.lastTime, - messageCount: allMessages.length, - avatar: undefined as string | undefined - }, - messages: allMessages + if (options.format === 'arkme-json' && isGroup) { + this.throwIfStopRequested(control) + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } - if (options.exportAvatars) { - const avatarMap = await this.exportAvatars( + const avatarMap = options.exportAvatars + ? await this.exportAvatars( [ ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ username, avatarUrl: info.avatarUrl })), - { username: sessionId, avatarUrl: sessionInfo.avatarUrl } + { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, + { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } ] ) - const avatars: Record = {} - for (const [username, relPath] of avatarMap.entries()) { - avatars[username] = relPath - } - if (Object.keys(avatars).length > 0) { - detailedExport.session = { - ...detailedExport.session, - avatar: avatars[sessionId] - } - ; (detailedExport as any).avatars = avatars - } + : new Map() + + const sessionPayload: any = { + wxid: sessionId, + nickname: sessionNickname, + remark: sessionRemark, + displayName: sessionDisplayName, + type: isGroup ? '群聊' : '私聊', + lastTimestamp: collected.lastTime, + messageCount: allMessages.length, + avatar: avatarMap.get(sessionId) } - fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + if (options.format === 'arkme-json') { + const senderIdMap = new Map() + const senders: Array<{ + senderID: number + wxid: string + displayName: string + nickname: string + remark?: string + groupNickname?: string + avatar?: string + }> = [] + const ensureSenderId = (senderWxidRaw: string): number => { + const senderWxid = String(senderWxidRaw || '').trim() || 'unknown' + const existed = senderIdMap.get(senderWxid) + if (existed) return existed + + const senderID = senders.length + 1 + senderIdMap.set(senderWxid, senderID) + + const profile = senderProfileMap.get(senderWxid) + const senderItem: { + senderID: number + wxid: string + displayName: string + nickname: string + remark?: string + groupNickname?: string + avatar?: string + } = { + senderID, + wxid: senderWxid, + displayName: profile?.displayName || senderWxid, + nickname: profile?.nickname || profile?.displayName || senderWxid + } + if (profile?.remark) senderItem.remark = profile.remark + if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname + const avatar = avatarMap.get(senderWxid) + if (avatar) senderItem.avatar = avatar + + senders.push(senderItem) + return senderID + } + + const compactMessages = allMessages.map((message) => { + this.throwIfStopRequested(control) + const senderID = ensureSenderId(String(message.senderUsername || '')) + const compactMessage: any = { + localId: message.localId, + createTime: message.createTime, + formattedTime: message.formattedTime, + type: message.type, + localType: message.localType, + content: message.content, + isSend: message.isSend, + senderID, + source: message.source + } + if (message.locationLat != null) compactMessage.locationLat = message.locationLat + if (message.locationLng != null) compactMessage.locationLng = message.locationLng + if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname + if (message.locationLabel) compactMessage.locationLabel = message.locationLabel + if (message.appMsgType) compactMessage.appMsgType = message.appMsgType + if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind + if (message.quotedContent) compactMessage.quotedContent = message.quotedContent + if (message.quotedSender) compactMessage.quotedSender = message.quotedSender + if (message.quotedType) compactMessage.quotedType = message.quotedType + if (message.finderTitle) compactMessage.finderTitle = message.finderTitle + if (message.finderDesc) compactMessage.finderDesc = message.finderDesc + if (message.finderUsername) compactMessage.finderUsername = message.finderUsername + if (message.finderNickname) compactMessage.finderNickname = message.finderNickname + if (message.finderCoverUrl) compactMessage.finderCoverUrl = message.finderCoverUrl + if (message.finderAvatar) compactMessage.finderAvatar = message.finderAvatar + if (message.finderDuration != null) compactMessage.finderDuration = message.finderDuration + if (message.finderObjectId) compactMessage.finderObjectId = message.finderObjectId + if (message.finderUrl) compactMessage.finderUrl = message.finderUrl + if (message.musicTitle) compactMessage.musicTitle = message.musicTitle + if (message.musicUrl) compactMessage.musicUrl = message.musicUrl + if (message.musicDataUrl) compactMessage.musicDataUrl = message.musicDataUrl + if (message.musicAlbumUrl) compactMessage.musicAlbumUrl = message.musicAlbumUrl + if (message.musicCoverUrl) compactMessage.musicCoverUrl = message.musicCoverUrl + if (message.musicSinger) compactMessage.musicSinger = message.musicSinger + if (message.musicAppName) compactMessage.musicAppName = message.musicAppName + if (message.musicSourceName) compactMessage.musicSourceName = message.musicSourceName + if (message.musicDuration != null) compactMessage.musicDuration = message.musicDuration + if (message.cardKind) compactMessage.cardKind = message.cardKind + if (message.contactCardWxid) compactMessage.contactCardWxid = message.contactCardWxid + if (message.contactCardNickname) compactMessage.contactCardNickname = message.contactCardNickname + if (message.contactCardAlias) compactMessage.contactCardAlias = message.contactCardAlias + if (message.contactCardRemark) compactMessage.contactCardRemark = message.contactCardRemark + if (message.contactCardGender != null) compactMessage.contactCardGender = message.contactCardGender + if (message.contactCardProvince) compactMessage.contactCardProvince = message.contactCardProvince + if (message.contactCardCity) compactMessage.contactCardCity = message.contactCardCity + if (message.contactCardSignature) compactMessage.contactCardSignature = message.contactCardSignature + if (message.contactCardAvatar) compactMessage.contactCardAvatar = message.contactCardAvatar + return compactMessage + }) + + const arkmeSession: any = { + ...sessionPayload + } + let groupMembers: Array<{ + wxid: string + displayName: string + nickname: string + remark: string + alias: string + groupNickname?: string + isFriend: boolean + messageCount: number + avatar?: string + }> | undefined + + if (isGroup) { + const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean) + await this.preloadContacts(memberUsernames, contactCache) + const friendLookupUsernames = this.buildGroupNicknameIdCandidates(memberUsernames) + const friendFlagMap = await this.queryFriendFlagMap(friendLookupUsernames) + const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0) + const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data + ? this.extractGroupSenderCountMap(groupStatsResult.data, sessionId) + : new Map() + + groupMembers = [] + for (const memberWxid of memberUsernames) { + this.throwIfStopRequested(control) + const member = collected.memberSet.get(memberWxid)?.member + const contactResult = await getContactCached(memberWxid) + const contact = contactResult.success ? contactResult.contact : null + const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid) + const remark = String(contact?.remark || '') + const alias = String(contact?.alias || '') + const groupNickname = member?.groupNickname || this.resolveGroupNicknameByCandidates( + groupNicknamesMap, + [memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias] + ) || '' + const displayName = this.getPreferredDisplayName( + memberWxid, + nickname, + remark, + groupNickname, + options.displayNamePreference || 'remark' + ) + + const groupMember: { + wxid: string + displayName: string + nickname: string + remark: string + alias: string + groupNickname?: string + isFriend: boolean + messageCount: number + avatar?: string + } = { + wxid: memberWxid, + displayName, + nickname, + remark, + alias, + isFriend: this.buildGroupNicknameIdCandidates([memberWxid]).some((candidate) => friendFlagMap.get(candidate) === true), + messageCount: this.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid) + } + if (groupNickname) groupMember.groupNickname = groupNickname + const avatar = avatarMap.get(memberWxid) + if (avatar) groupMember.avatar = avatar + groupMembers.push(groupMember) + } + groupMembers.sort((a, b) => { + if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount + return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN') + }) + } + + const arkmeExport: any = { + weflow: { + ...weflow, + format: 'arkme-json' + }, + session: arkmeSession, + senders, + messages: compactMessages + } + if (groupMembers) { + arkmeExport.groupMembers = groupMembers + } + + this.throwIfStopRequested(control) + fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') + } else { + const detailedExport: any = { + weflow, + session: sessionPayload, + messages: allMessages + } + + if (options.exportAvatars) { + const avatars: Record = {} + for (const [username, relPath] of avatarMap.entries()) { + avatars[username] = relPath + } + if (Object.keys(avatars).length > 0) { + detailedExport.session = { + ...detailedExport.session, + avatar: avatars[sessionId] + } + ; (detailedExport as any).avatars = avatars + } + } + + this.throwIfStopRequested(control) + fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + } onProgress?.({ current: 100, @@ -3059,6 +4259,9 @@ class ExportService { return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -3070,9 +4273,11 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -3104,7 +4309,18 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -3120,7 +4336,11 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -3281,6 +4501,7 @@ class ExportService { const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { + this.throwIfStopRequested(control) const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -3324,6 +4545,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 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) voiceTranscribed++ @@ -3348,6 +4570,9 @@ class ExportService { // ========== 写入 Excel 行 ========== for (let i = 0; i < sortedMessages.length; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] // 确定发送者信息 @@ -3499,6 +4724,7 @@ class ExportService { }) // 写入文件 + this.throwIfStopRequested(control) await workbook.xlsx.writeFile(outputPath) onProgress?.({ @@ -3510,6 +4736,9 @@ class ExportService { 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')) { @@ -3563,9 +4792,11 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -3591,7 +4822,18 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -3607,7 +4849,11 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -3654,6 +4900,7 @@ class ExportService { const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { + this.throwIfStopRequested(control) const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -3696,6 +4943,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 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) voiceTranscribed++ @@ -3721,6 +4969,9 @@ class ExportService { const lines: string[] = [] for (let i = 0; i < sortedMessages.length; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] const mediaKey = `${msg.localType}_${msg.localId}` const mediaItem = mediaCache.get(mediaKey) @@ -3821,6 +5072,7 @@ class ExportService { phase: 'writing' }) + this.throwIfStopRequested(control) fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') onProgress?.({ @@ -3832,6 +5084,9 @@ class ExportService { return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -3843,9 +5098,11 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -3871,13 +5128,28 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) if (collected.rows.length === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -3931,6 +5203,7 @@ class ExportService { const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { + this.throwIfStopRequested(control) const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -3973,6 +5246,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 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) voiceTranscribed++ @@ -3999,6 +5273,9 @@ class ExportService { lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') for (let i = 0; i < sortedMessages.length; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] const mediaKey = `${msg.localType}_${msg.localId}` const mediaItem = mediaCache.get(mediaKey) || null @@ -4076,6 +5353,7 @@ class ExportService { phase: 'writing' }) + this.throwIfStopRequested(control) fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') onProgress?.({ @@ -4087,6 +5365,9 @@ class ExportService { return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -4182,9 +5463,11 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -4213,7 +5496,18 @@ class ExportService { await this.ensureVoiceModel(onProgress) } - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -4221,7 +5515,11 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -4239,6 +5537,7 @@ class ExportService { : new Map() if (isGroup) { + this.throwIfStopRequested(control) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) @@ -4270,6 +5569,7 @@ class ExportService { const MEDIA_CONCURRENCY = 6 let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -4317,6 +5617,7 @@ class ExportService { const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 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) voiceTranscribed++ @@ -4360,6 +5661,7 @@ class ExportService { const writePromise = (str: string) => { return new Promise((resolve, reject) => { + this.throwIfStopRequested(control) if (!stream.write(str)) { stream.once('drain', resolve) } else { @@ -4426,6 +5728,9 @@ class ExportService { let writeBuf: string[] = [] for (let i = 0; i < sortedMessages.length; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] const mediaKey = `${msg.localType}_${msg.localId}` const mediaItem = mediaCache.get(mediaKey) || null @@ -4659,6 +5964,9 @@ class ExportService { }) } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -4669,61 +5977,220 @@ class ExportService { async getExportStats( sessionIds: string[], options: ExportOptions - ): Promise<{ - totalMessages: number - voiceMessages: number - cachedVoiceCount: number - needTranscribeCount: number - mediaMessages: number - estimatedSeconds: number - sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> - }> { + ): Promise { const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) { return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } } + const normalizedSessionIds = this.normalizeSessionIds(sessionIds) + if (normalizedSessionIds.length === 0) { + return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } + } + const cacheKey = this.buildExportStatsCacheKey(normalizedSessionIds, options, conn.cleanedWxid) + const cachedStats = this.getExportStatsCacheEntry(cacheKey) + if (cachedStats) { + const cachedResult = this.cloneExportStatsResult(cachedStats.result) + const orderedSessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + const sessionMap = new Map(cachedResult.sessions.map((item) => [item.sessionId, item] as const)) + for (const sessionId of normalizedSessionIds) { + const cachedSession = sessionMap.get(sessionId) + if (cachedSession) orderedSessions.push(cachedSession) + } + if (orderedSessions.length === cachedResult.sessions.length) { + cachedResult.sessions = orderedSessions + } + return cachedResult + } + const cleanedMyWxid = conn.cleanedWxid const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + const sessionSnapshotMap: Record = {} let totalMessages = 0 let voiceMessages = 0 let cachedVoiceCount = 0 let mediaMessages = 0 - for (const sessionId of sessionIds) { - const sessionInfo = await this.getContactInfo(sessionId) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) - const msgs = collected.rows - const voiceMsgs = msgs.filter(m => m.localType === 34) - const mediaMsgs = msgs.filter(m => { - const t = m.localType - return (t === 3) || (t === 47) || (t === 43) || (t === 34) - }) + const hasSenderFilter = Boolean(String(options.senderUsername || '').trim()) + const canUseAggregatedStats = this.isUnboundedDateRange(options.dateRange) && !hasSenderFilter - // 检查已缓存的转写数量 - let cached = 0 - for (const msg of voiceMsgs) { - if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { - cached++ + // 快速路径:直接复用 ChatService 聚合统计,避免逐会话 collectMessages 扫全量消息。 + if (canUseAggregatedStats) { + try { + let aggregatedData = this.getAggregatedSessionStatsCache(cacheKey) + if (!aggregatedData) { + const statsResult = await chatService.getExportSessionStats(normalizedSessionIds, { + includeRelations: false, + allowStaleCache: true + }) + if (statsResult.success && statsResult.data) { + aggregatedData = statsResult.data as Record + this.setAggregatedSessionStatsCache(cacheKey, aggregatedData) + } } + if (aggregatedData) { + const cachedVoiceCountMap = chatService.getCachedVoiceTranscriptCountMap(normalizedSessionIds) + const fastRows = await parallelLimit( + normalizedSessionIds, + 8, + async (sessionId): Promise<{ + sessionId: string + displayName: string + totalCount: number + voiceCount: number + cachedVoiceCount: number + mediaCount: number + }> => { + let displayName = sessionId + try { + const sessionInfo = await this.getContactInfo(sessionId) + displayName = sessionInfo.displayName || sessionId + } catch { + // 预估阶段显示名获取失败不阻塞统计 + } + + const metric = aggregatedData?.[sessionId] + const totalCount = Number.isFinite(metric?.totalMessages) + ? Math.max(0, Math.floor(metric!.totalMessages)) + : 0 + const voiceCount = Number.isFinite(metric?.voiceMessages) + ? Math.max(0, Math.floor(metric!.voiceMessages)) + : 0 + const imageCount = Number.isFinite(metric?.imageMessages) + ? Math.max(0, Math.floor(metric!.imageMessages)) + : 0 + const videoCount = Number.isFinite(metric?.videoMessages) + ? Math.max(0, Math.floor(metric!.videoMessages)) + : 0 + const emojiCount = Number.isFinite(metric?.emojiMessages) + ? Math.max(0, Math.floor(metric!.emojiMessages)) + : 0 + const lastTimestamp = Number.isFinite(metric?.lastTimestamp) + ? Math.max(0, Math.floor(metric!.lastTimestamp)) + : undefined + const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) + const sessionCachedVoiceCount = Math.min( + voiceCount, + Number.isFinite(cachedCountRaw) ? Math.max(0, Math.floor(cachedCountRaw)) : 0 + ) + + sessionSnapshotMap[sessionId] = { + totalCount, + voiceCount, + imageCount, + videoCount, + emojiCount, + cachedVoiceCount: sessionCachedVoiceCount, + lastTimestamp + } + + return { + sessionId, + displayName, + totalCount, + voiceCount, + cachedVoiceCount: sessionCachedVoiceCount, + mediaCount: voiceCount + imageCount + videoCount + emojiCount + } + } + ) + + for (const row of fastRows) { + totalMessages += row.totalCount + voiceMessages += row.voiceCount + cachedVoiceCount += row.cachedVoiceCount + mediaMessages += row.mediaCount + sessionsStats.push({ + sessionId: row.sessionId, + displayName: row.displayName, + totalCount: row.totalCount, + voiceCount: row.voiceCount + }) + } + + const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) + const estimatedSeconds = needTranscribeCount * 2 + const result: ExportStatsResult = { + totalMessages, + voiceMessages, + cachedVoiceCount, + needTranscribeCount, + mediaMessages, + estimatedSeconds, + sessions: sessionsStats + } + this.setExportStatsCacheEntry(cacheKey, { + createdAt: Date.now(), + result: this.cloneExportStatsResult(result), + sessions: { ...sessionSnapshotMap } + }) + return result + } + } catch (error) { + // 聚合统计失败时自动回退到慢路径,保证功能正确。 } + } + + // 回退路径:保留旧逻辑,支持有时间范围/发送者过滤等需要精确筛选的场景。 + for (const sessionId of normalizedSessionIds) { + const sessionInfo = await this.getContactInfo(sessionId) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + 'text-fast' + ) + const msgs = collected.rows + let voiceCount = 0 + let imageCount = 0 + let videoCount = 0 + let emojiCount = 0 + let latestTimestamp = 0 + let cached = 0 + for (const msg of msgs) { + if (msg.createTime > latestTimestamp) { + latestTimestamp = msg.createTime + } + const localType = msg.localType + if (localType === 34) { + voiceCount++ + if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { + cached++ + } + continue + } + if (localType === 3) imageCount++ + if (localType === 43) videoCount++ + if (localType === 47) emojiCount++ + } + const mediaCount = voiceCount + imageCount + videoCount + emojiCount totalMessages += msgs.length - voiceMessages += voiceMsgs.length + voiceMessages += voiceCount cachedVoiceCount += cached - mediaMessages += mediaMsgs.length + mediaMessages += mediaCount + sessionSnapshotMap[sessionId] = { + totalCount: msgs.length, + voiceCount, + imageCount, + videoCount, + emojiCount, + cachedVoiceCount: cached, + lastTimestamp: latestTimestamp > 0 ? latestTimestamp : undefined + } sessionsStats.push({ sessionId, displayName: sessionInfo.displayName, totalCount: msgs.length, - voiceCount: voiceMsgs.length + voiceCount }) } - const needTranscribeCount = voiceMessages - cachedVoiceCount + const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) // 预估:每条语音转文字约 2 秒 const estimatedSeconds = needTranscribeCount * 2 - return { + const result: ExportStatsResult = { totalMessages, voiceMessages, cachedVoiceCount, @@ -4732,6 +6199,12 @@ class ExportService { estimatedSeconds, sessions: sessionsStats } + this.setExportStatsCacheEntry(cacheKey, { + createdAt: Date.now(), + result: this.cloneExportStatsResult(result), + sessions: { ...sessionSnapshotMap } + }) + return result } /** @@ -4741,10 +6214,23 @@ class ExportService { sessionIds: string[], outputDir: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void - ): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> { + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl + ): Promise<{ + success: boolean + successCount: number + failCount: number + paused?: boolean + stopped?: boolean + pendingSessionIds?: string[] + successSessionIds?: string[] + failedSessionIds?: string[] + error?: string + }> { let successCount = 0 let failCount = 0 + const successSessionIds: string[] = [] + const failedSessionIds: string[] = [] try { const conn = await this.ensureConnected() @@ -4752,104 +6238,451 @@ class ExportService { return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } } - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }) - } + const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) + ? { ...options, exportVoiceAsText: false } + : options - const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + const exportMediaEnabled = effectiveOptions.exportMedia === true && + Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) + const rawWriteLayout = this.configService.get('exportWriteLayout') + const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' + ? rawWriteLayout + : 'A' + const exportBaseDir = writeLayout === 'A' + ? path.join(outputDir, 'texts') + : outputDir + if (!fs.existsSync(exportBaseDir)) { + fs.mkdirSync(exportBaseDir, { recursive: true }) + } const sessionLayout = exportMediaEnabled - ? (options.sessionLayout ?? 'per-session') + ? (effectiveOptions.sessionLayout ?? 'per-session') : 'shared' let completedCount = 0 - const rawConcurrency = typeof options.exportConcurrency === 'number' - ? Math.floor(options.exportConcurrency) - : 2 - const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6)) - const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared') - ? 1 - : clampedConcurrency - - await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { - const sessionInfo = await this.getContactInfo(sessionId) - - // 创建包装后的进度回调,自动附加会话级信息 - const sessionProgress = (progress: ExportProgress) => { + const activeSessionRatios = new Map() + const computeAggregateCurrent = () => { + let activeRatioSum = 0 + for (const ratio of activeSessionRatios.values()) { + activeRatioSum += Math.max(0, Math.min(1, ratio)) + } + return Math.min(sessionIds.length, completedCount + activeRatioSum) + } + const isTextContentBatchExport = effectiveOptions.contentType === 'text' && !exportMediaEnabled + const defaultConcurrency = exportMediaEnabled ? 2 : (isTextContentBatchExport ? 1 : 4) + const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number' + ? Math.floor(effectiveOptions.exportConcurrency) + : defaultConcurrency + const maxSessionConcurrency = isTextContentBatchExport ? 1 : 6 + const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, maxSessionConcurrency)) + const sessionConcurrency = clampedConcurrency + const queue = [...sessionIds] + let pauseRequested = false + let stopRequested = false + const emptySessionIds = new Set() + const sessionMessageCountHints = new Map() + const sessionLatestTimestampHints = new Map() + const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid) + const cachedStatsEntry = this.getExportStatsCacheEntry(exportStatsCacheKey) + if (cachedStatsEntry?.sessions) { + for (const sessionId of sessionIds) { + const snapshot = cachedStatsEntry.sessions[sessionId] + if (!snapshot) continue + sessionMessageCountHints.set(sessionId, Math.max(0, Math.floor(snapshot.totalCount || 0))) + if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) { + sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp))) + } + if (snapshot.totalCount <= 0) { + emptySessionIds.add(sessionId) + } + } + } + const canUseSessionSnapshotHints = isTextContentBatchExport && + this.isUnboundedDateRange(effectiveOptions.dateRange) && + !String(effectiveOptions.senderUsername || '').trim() + const canFastSkipEmptySessions = !isTextContentBatchExport && + this.isUnboundedDateRange(effectiveOptions.dateRange) && + !String(effectiveOptions.senderUsername || '').trim() + const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints + const precheckSessionIds = canFastSkipEmptySessions + ? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId)) + : [] + if (canFastSkipEmptySessions && precheckSessionIds.length > 0) { + const EMPTY_SESSION_PRECHECK_LIMIT = 1200 + if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) { + let checkedCount = 0 onProgress?.({ - ...progress, - current: completedCount, + current: computeAggregateCurrent(), total: sessionIds.length, - currentSession: sessionInfo.displayName + currentSession: '', + currentSessionId: '', + phase: 'preparing', + phaseProgress: 0, + phaseTotal: precheckSessionIds.length, + phaseLabel: `预检查空会话 0/${precheckSessionIds.length}` + }) + + const PRECHECK_BATCH_SIZE = 160 + for (let i = 0; i < precheckSessionIds.length; i += PRECHECK_BATCH_SIZE) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const batchSessionIds = precheckSessionIds.slice(i, i + PRECHECK_BATCH_SIZE) + const countsResult = await wcdbService.getMessageCounts(batchSessionIds) + if (countsResult.success && countsResult.counts) { + for (const batchSessionId of batchSessionIds) { + const count = countsResult.counts[batchSessionId] + if (typeof count === 'number' && Number.isFinite(count) && count >= 0) { + sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count))) + } + if (typeof count === 'number' && Number.isFinite(count) && count <= 0) { + emptySessionIds.add(batchSessionId) + } + } + } + + checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length) + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: '', + currentSessionId: '', + phase: 'preparing', + phaseProgress: checkedCount, + phaseTotal: precheckSessionIds.length, + phaseLabel: `预检查空会话 ${checkedCount}/${precheckSessionIds.length}` + }) + } + } else { + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: '', + currentSessionId: '', + phase: 'preparing', + phaseLabel: `会话较多,已跳过空会话预检查(${precheckSessionIds.length} 个)` }) } + } - sessionProgress({ - current: completedCount, - total: sessionIds.length, - currentSession: sessionInfo.displayName, - phase: 'exporting' + if (canUseSessionSnapshotHints && sessionIds.length > 0) { + const missingHintSessionIds = sessionIds.filter((sessionId) => ( + !sessionMessageCountHints.has(sessionId) || !sessionLatestTimestampHints.has(sessionId) + )) + if (missingHintSessionIds.length > 0) { + const sessionSet = new Set(missingHintSessionIds) + const sessionsResult = await chatService.getSessions() + if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { + for (const item of sessionsResult.sessions) { + const username = String(item?.username || '').trim() + if (!username) continue + if (!sessionSet.has(username)) continue + const messageCountHint = Number(item?.messageCountHint) + if ( + !sessionMessageCountHints.has(username) && + Number.isFinite(messageCountHint) && + messageCountHint >= 0 + ) { + sessionMessageCountHints.set(username, Math.floor(messageCountHint)) + } + const lastTimestamp = Number(item?.lastTimestamp) + if ( + !sessionLatestTimestampHints.has(username) && + Number.isFinite(lastTimestamp) && + lastTimestamp > 0 + ) { + sessionLatestTimestampHints.set(username, Math.floor(lastTimestamp)) + } + } + } + } + } + + if (stopRequested) { + return { + success: true, + successCount, + failCount, + stopped: true, + pendingSessionIds: [...queue], + successSessionIds, + failedSessionIds + } + } + if (pauseRequested) { + return { + success: true, + successCount, + failCount, + paused: true, + pendingSessionIds: [...queue], + successSessionIds, + failedSessionIds + } + } + + const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => { + try { + this.throwIfStopRequested(control) + const sessionInfo = await this.getContactInfo(sessionId) + const messageCountHint = sessionMessageCountHints.get(sessionId) + const latestTimestampHint = sessionLatestTimestampHints.get(sessionId) + + if ( + isTextContentBatchExport && + typeof messageCountHint === 'number' && + messageCountHint <= 0 + ) { + successCount++ + successSessionIds.push(sessionId) + activeSessionRatios.delete(sessionId) + completedCount++ + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '该会话没有消息,已跳过' + }) + return 'done' + } + + if (emptySessionIds.has(sessionId)) { + successCount++ + successSessionIds.push(sessionId) + activeSessionRatios.delete(sessionId) + completedCount++ + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '该会话没有消息,已跳过' + }) + return 'done' + } + + const sessionProgress = (progress: ExportProgress) => { + const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100 + const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0 + const ratio = progress.phase === 'complete' + ? 1 + : Math.max(0, Math.min(1, phaseCurrent / phaseTotal)) + activeSessionRatios.set(sessionId, ratio) + onProgress?.({ + ...progress, + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId + }) + } + + sessionProgress({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing', + phaseLabel: '准备导出' + }) + + const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() + const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' + const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '') + const safeName = suffix ? `${baseName}_${suffix}` : baseName + const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false + const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' + const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` + const useSessionFolder = sessionLayout === 'per-session' + const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName + const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir + + if (useSessionFolder && !fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }) + } + + let ext = '.json' + if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl' + else if (effectiveOptions.format === 'excel') ext = '.xlsx' + else if (effectiveOptions.format === 'txt') ext = '.txt' + else if (effectiveOptions.format === 'weclone') ext = '.csv' + else if (effectiveOptions.format === 'html') ext = '.html' + const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) + const canTrySkipUnchanged = canTrySkipUnchangedTextSessions && + typeof messageCountHint === 'number' && + messageCountHint >= 0 && + typeof latestTimestampHint === 'number' && + latestTimestampHint > 0 && + fs.existsSync(outputPath) + if (canTrySkipUnchanged) { + const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) + const hasNoDataChange = Boolean( + latestRecord && + latestRecord.messageCount === messageCountHint && + Number(latestRecord.sourceLatestMessageTimestamp || 0) >= latestTimestampHint + ) + if (hasNoDataChange) { + successCount++ + successSessionIds.push(sessionId) + activeSessionRatios.delete(sessionId) + completedCount++ + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '无变化,已跳过' + }) + return 'done' + } + } + + let result: { success: boolean; error?: string } + if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { + result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') { + result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'excel') { + result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'txt') { + result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'weclone') { + result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else { + result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` } + } + + if (!result.success && this.isStopError(result.error)) { + activeSessionRatios.delete(sessionId) + return 'stopped' + } + + if (result.success) { + successCount++ + successSessionIds.push(sessionId) + if (typeof messageCountHint === 'number' && messageCountHint >= 0) { + exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, { + sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0 + ? latestTimestampHint + : undefined, + outputPath + }) + } + } else { + failCount++ + failedSessionIds.push(sessionId) + console.error(`导出 ${sessionId} 失败:`, result.error) + } + + activeSessionRatios.delete(sessionId) + completedCount++ + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: result.success ? '完成' : '导出失败' + }) + return 'done' + } catch (error) { + if (this.isStopError(error)) { + activeSessionRatios.delete(sessionId) + return 'stopped' + } + throw error + } + } + + if (isTextContentBatchExport) { + // 文本内容批量导出使用串行调度,降低数据库与文件系统抢占,行为更贴近 wxdaochu。 + while (queue.length > 0) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const sessionId = queue.shift() + if (!sessionId) break + const runState = await runOne(sessionId) + await new Promise(resolve => setImmediate(resolve)) + if (runState === 'stopped') { + stopRequested = true + queue.unshift(sessionId) + break + } + } + } else { + const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => { + while (queue.length > 0) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const sessionId = queue.shift() + if (!sessionId) break + const runState = await runOne(sessionId) + if (runState === 'stopped') { + stopRequested = true + queue.unshift(sessionId) + break + } + } }) + await Promise.all(workers) + } - const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() - const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' - const suffix = sanitizeName(options.fileNameSuffix || '') - const safeName = suffix ? `${baseName}_${suffix}` : baseName - const useSessionFolder = sessionLayout === 'per-session' - const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - - if (useSessionFolder && !fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) + const pendingSessionIds = [...queue] + if (stopRequested && pendingSessionIds.length > 0) { + return { + success: true, + successCount, + failCount, + stopped: true, + pendingSessionIds, + successSessionIds, + failedSessionIds } - - let ext = '.json' - if (options.format === 'chatlab-jsonl') ext = '.jsonl' - else if (options.format === 'excel') ext = '.xlsx' - else if (options.format === 'txt') ext = '.txt' - else if (options.format === 'weclone') ext = '.csv' - else if (options.format === 'html') ext = '.html' - const outputPath = path.join(sessionDir, `${safeName}${ext}`) - - let result: { success: boolean; error?: string } - if (options.format === 'json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'weclone') { - result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) - } else { - result = { success: false, error: `不支持的格式: ${options.format}` } + } + if (pauseRequested && pendingSessionIds.length > 0) { + return { + success: true, + successCount, + failCount, + paused: true, + pendingSessionIds, + successSessionIds, + failedSessionIds } - - if (result.success) { - successCount++ - } else { - failCount++ - console.error(`导出 ${sessionId} 失败:`, result.error) - } - - completedCount++ - onProgress?.({ - current: completedCount, - total: sessionIds.length, - currentSession: sessionInfo.displayName, - phase: 'exporting' - }) - }) + } onProgress?.({ current: sessionIds.length, total: sessionIds.length, currentSession: '', + currentSessionId: '', phase: 'complete' }) - return { success: true, successCount, failCount } + return { success: true, successCount, failCount, successSessionIds, failedSessionIds } } catch (e) { return { success: false, successCount, failCount, error: String(e) } } diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 22abcb9..fbdb32e 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -21,6 +21,12 @@ export interface GroupMember { alias?: string remark?: string groupNickname?: string + isOwner?: boolean +} + +export interface GroupMembersPanelEntry extends GroupMember { + isFriend: boolean + messageCount: number } export interface GroupMessageRank { @@ -43,8 +49,28 @@ export interface GroupMediaStats { total: number } +interface GroupMemberContactInfo { + remark: string + nickName: string + alias: string + username: string + userName: string + encryptUsername: string + encryptUserName: string + localType: number +} + class GroupAnalyticsService { private configService: ConfigService + private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000 + private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000 + private readonly groupMembersPanelFullTimeoutMs = 25 * 1000 + private readonly groupMembersPanelCache = new Map() + private readonly groupMembersPanelInFlight = new Map< + string, + Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> + >() + private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) constructor() { this.configService = new ConfigService() @@ -89,6 +115,128 @@ class GroupAnalyticsService { return cleaned } + private resolveMemberUsername( + candidate: unknown, + memberLookup: Map + ): string | null { + if (typeof candidate !== 'string') return null + const raw = candidate.trim() + if (!raw) return null + if (memberLookup.has(raw)) return memberLookup.get(raw) || null + const cleaned = this.cleanAccountDirName(raw) + if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null + + const parts = raw.split(/[,\s;|]+/).filter(Boolean) + for (const part of parts) { + if (memberLookup.has(part)) return memberLookup.get(part) || null + const normalizedPart = this.cleanAccountDirName(part) + if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null + } + + if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) { + try { + const parsed = JSON.parse(raw) + return this.extractOwnerUsername(parsed, memberLookup, 0) + } catch { + return null + } + } + + return null + } + + private extractOwnerUsername( + value: unknown, + memberLookup: Map, + depth: number + ): string | null { + if (depth > 4 || value == null) return null + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null + + if (typeof value === 'string') { + return this.resolveMemberUsername(value, memberLookup) + } + + if (Array.isArray(value)) { + for (const item of value) { + const owner = this.extractOwnerUsername(item, memberLookup, depth + 1) + if (owner) return owner + } + return null + } + + if (typeof value !== 'object') return null + const row = value as Record + + for (const [key, entry] of Object.entries(row)) { + const keyLower = key.toLowerCase() + if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) { + continue + } + + if (typeof entry === 'boolean') { + if (entry && typeof row.username === 'string') { + const owner = this.resolveMemberUsername(row.username, memberLookup) + if (owner) return owner + } + continue + } + + const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1) + if (owner) return owner + } + + return null + } + + private async detectGroupOwnerUsername( + chatroomId: string, + members: Array<{ username: string; [key: string]: unknown }> + ): Promise { + const memberLookup = new Map() + for (const member of members) { + const username = String(member.username || '').trim() + if (!username) continue + const cleaned = this.cleanAccountDirName(username) + memberLookup.set(username, username) + memberLookup.set(cleaned, username) + } + if (memberLookup.size === 0) return undefined + + const tryResolve = (candidate: unknown): string | undefined => { + const owner = this.extractOwnerUsername(candidate, memberLookup, 0) + return owner || undefined + } + + for (const member of members) { + const owner = tryResolve(member) + if (owner) return owner + } + + try { + const groupContact = await wcdbService.getContact(chatroomId) + if (groupContact.success && groupContact.contact) { + const owner = tryResolve(groupContact.contact) + if (owner) return owner + } + } catch { + // ignore + } + + 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]) + if (owner) return owner + } + } catch { + // ignore + } + + return undefined + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -296,6 +444,203 @@ class GroupAnalyticsService { return Array.from(set) } + private toNonNegativeInteger(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 0 + return Math.max(0, Math.floor(parsed)) + } + + private pickStringField(row: Record, keys: string[]): string { + for (const key of keys) { + const value = row[key] + if (value == null) continue + const text = String(value).trim() + if (text) return text + } + return '' + } + + private pickIntegerField(row: Record, keys: string[], fallback: number = 0): number { + for (const key of keys) { + const value = row[key] + if (value == null || value === '') continue + const parsed = Number(value) + if (Number.isFinite(parsed)) return Math.floor(parsed) + } + return fallback + } + + private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim()) + const mode = includeMessageCounts ? 'full' : 'members' + return `${dbPath}::${wxid}::${chatroomId}::${mode}` + } + + private pruneGroupMembersPanelCache(maxEntries: number = 80): void { + if (this.groupMembersPanelCache.size <= maxEntries) return + const entries = Array.from(this.groupMembersPanelCache.entries()) + .sort((a, b) => a[1].updatedAt - b[1].updatedAt) + const removeCount = this.groupMembersPanelCache.size - maxEntries + for (let i = 0; i < removeCount; i += 1) { + this.groupMembersPanelCache.delete(entries[i][0]) + } + } + + private async withPromiseTimeout( + promise: Promise, + timeoutMs: number, + timeoutResult: T + ): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return promise + } + + let timeoutTimer: ReturnType | null = null + const timeoutPromise = new Promise((resolve) => { + timeoutTimer = setTimeout(() => { + resolve(timeoutResult) + }, timeoutMs) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutTimer) { + clearTimeout(timeoutTimer) + } + } + } + + private async buildGroupMemberContactLookup(usernames: string[]): Promise> { + const lookup = new Map() + const candidates = this.buildIdCandidates(usernames) + if (candidates.length === 0) return lookup + + const appendContactsToLookup = (rows: Record[]) => { + for (const row of rows) { + const contact: GroupMemberContactInfo = { + remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']), + nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']), + alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']), + username: this.pickStringField(row, ['username', 'WCDB_CT_username']), + userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']), + encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']), + encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']), + localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + } + const lookupKeys = this.buildIdCandidates([ + contact.username, + contact.userName, + contact.encryptUsername, + contact.encryptUserName, + contact.alias + ]) + for (const key of lookupKeys) { + const normalized = key.toLowerCase() + if (!lookup.has(normalized)) { + lookup.set(normalized, contact) + } + } + } + } + + const batchSize = 200 + for (let i = 0; i < candidates.length; i += batchSize) { + 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[]) + } + return lookup + } + + private resolveContactByCandidates( + lookup: Map, + candidates: Array + ): GroupMemberContactInfo | undefined { + const ids = this.buildIdCandidates(candidates) + for (const id of ids) { + const hit = lookup.get(id.toLowerCase()) + if (hit) return hit + } + return undefined + } + + private async buildGroupMessageCountLookup(chatroomId: string): Promise> { + const lookup = new Map() + const result = await wcdbService.getGroupStats(chatroomId, 0, 0) + if (!result.success || !result.data) return lookup + + const sessionData = result.data?.sessions?.[chatroomId] + if (!sessionData || !sessionData.senders) return lookup + + const idMap = result.data.idMap || {} + for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record)) { + const username = String(idMap[senderId] || senderId || '').trim() + if (!username) continue + const count = this.toNonNegativeInteger(rawCount) + const keys = this.buildIdCandidates([username]) + for (const key of keys) { + const normalized = key.toLowerCase() + const prev = lookup.get(normalized) || 0 + if (count > prev) { + lookup.set(normalized, count) + } + } + } + return lookup + } + + private resolveMessageCountByCandidates( + lookup: Map, + candidates: Array + ): number { + let maxCount = 0 + const ids = this.buildIdCandidates(candidates) + for (const id of ids) { + const count = lookup.get(id.toLowerCase()) + if (typeof count === 'number' && count > maxCount) { + maxCount = count + } + } + return maxCount + } + + private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean { + const normalizedWxid = String(wxid || '').trim().toLowerCase() + if (!normalizedWxid) return false + if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false + if (this.friendExcludeNames.has(normalizedWxid)) return false + if (!contact) return false + return contact.localType === 1 + } + + private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] { + return members.sort((a, b) => { + const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) + if (ownerDiff !== 0) return ownerDiff + + const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend)) + if (friendDiff !== 0) return friendDiff + + if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount + return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') + }) + } + private resolveGroupNicknameByCandidates(groupNicknames: Map, candidates: string[]): string { const idCandidates = this.buildIdCandidates(candidates) if (idCandidates.length === 0) return '' @@ -483,6 +828,167 @@ class GroupAnalyticsService { } } + private async loadGroupMembersPanelDataFresh( + chatroomId: string, + includeMessageCounts: boolean + ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> { + const membersResult = await wcdbService.getGroupMembers(chatroomId) + if (!membersResult.success || !membersResult.members) { + return { success: false, error: membersResult.error || '获取群成员失败' } + } + + const members = membersResult.members as Array<{ + username: string + avatarUrl?: string + originalName?: string + [key: string]: unknown + }> + if (members.length === 0) return { success: true, data: [] } + + const usernames = members + .map((member) => String(member.username || '').trim()) + .filter(Boolean) + if (usernames.length === 0) return { success: true, data: [] } + + const displayNamesPromise = wcdbService.getDisplayNames(usernames) + const contactLookupPromise = this.buildGroupMemberContactLookup(usernames) + const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members) + const messageCountLookupPromise = includeMessageCounts + ? this.buildGroupMessageCountLookup(chatroomId) + : Promise.resolve(new Map()) + + const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([ + displayNamesPromise, + contactLookupPromise, + ownerPromise, + messageCountLookupPromise + ]) + + const nicknameCandidates = this.buildIdCandidates([ + ...members.map((member) => member.username), + ...members.map((member) => member.originalName), + ...Array.from(contactLookup.values()).map((contact) => contact?.username), + ...Array.from(contactLookup.values()).map((contact) => contact?.userName), + ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername), + ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName), + ...Array.from(contactLookup.values()).map((contact) => contact?.alias) + ]) + const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) + const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + let myGroupMessageCountHint: number | undefined + + const data: GroupMembersPanelEntry[] = members + .map((member) => { + const wxid = String(member.username || '').trim() + if (!wxid) return null + + const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName]) + const nickname = contact?.nickName || '' + const remark = contact?.remark || '' + const alias = contact?.alias || '' + const normalizedWxid = this.cleanAccountDirName(wxid) + const lookupCandidates = this.buildIdCandidates([ + wxid, + member.originalName as string | undefined, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias + ]) + if (normalizedWxid === myWxid) { + lookupCandidates.push(myWxid) + } + const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) + const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid + + return { + username: wxid, + displayName, + nickname, + alias, + remark, + groupNickname, + avatarUrl: member.avatarUrl, + isOwner: Boolean(ownerUsername && ownerUsername === wxid), + isFriend: this.isFriendMember(wxid, contact), + messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates) + } + }) + .filter((member): member is GroupMembersPanelEntry => Boolean(member)) + + if (includeMessageCounts && myWxid) { + const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid) + if (selfEntry && Number.isFinite(selfEntry.messageCount)) { + myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount)) + } + } + + if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) { + void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number) + } + + return { success: true, data: this.sortGroupMembersPanelEntries(data) } + } + + async getGroupMembersPanelData( + chatroomId: string, + options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } + ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> { + try { + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } + + const forceRefresh = Boolean(options?.forceRefresh) + const includeMessageCounts = options?.includeMessageCounts !== false + const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts) + const now = Date.now() + const cached = this.groupMembersPanelCache.get(cacheKey) + if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) { + return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt } + } + + if (!forceRefresh) { + const pending = this.groupMembersPanelInFlight.get(cacheKey) + if (pending) return pending + } + + const requestPromise = (async () => { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const timeoutMs = includeMessageCounts + ? this.groupMembersPanelFullTimeoutMs + : this.groupMembersPanelMembersTimeoutMs + const fresh = await this.withPromiseTimeout( + this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts), + timeoutMs, + { + success: false, + error: includeMessageCounts + ? '群成员发言统计加载超时,请稍后重试' + : '群成员列表加载超时,请稍后重试' + } + ) + if (!fresh.success || !fresh.data) { + return { success: false, error: fresh.error || '获取群成员面板数据失败' } + } + + const updatedAt = Date.now() + this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data }) + this.pruneGroupMembersPanelCache() + return { success: true, data: fresh.data, fromCache: false, updatedAt } + })().finally(() => { + this.groupMembersPanelInFlight.delete(cacheKey) + }) + + this.groupMembersPanelInFlight.set(cacheKey, requestPromise) + return await requestPromise + } catch (e) { + return { success: false, error: String(e) } + } + } + async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> { try { const conn = await this.ensureConnected() @@ -497,6 +1003,7 @@ class GroupAnalyticsService { username: string avatarUrl?: string originalName?: string + [key: string]: unknown }> const usernames = members.map((m) => m.username).filter(Boolean) @@ -543,6 +1050,7 @@ class GroupAnalyticsService { const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members) const data: GroupMember[] = members.map((m) => { const wxid = m.username || '' const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid @@ -572,7 +1080,8 @@ class GroupAnalyticsService { alias, remark, groupNickname, - avatarUrl: m.avatarUrl + avatarUrl: m.avatarUrl, + isOwner: Boolean(ownerUsername && ownerUsername === wxid) } }) diff --git a/electron/services/groupMyMessageCountCacheService.ts b/electron/services/groupMyMessageCountCacheService.ts new file mode 100644 index 0000000..68ee346 --- /dev/null +++ b/electron/services/groupMyMessageCountCacheService.ts @@ -0,0 +1,204 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_GROUP_ENTRIES_PER_SCOPE = 3000 +const MAX_SCOPE_ENTRIES = 12 + +export interface GroupMyMessageCountCacheEntry { + updatedAt: number + messageCount: number +} + +interface GroupMyMessageCountScopeMap { + [chatroomId: string]: GroupMyMessageCountCacheEntry +} + +interface GroupMyMessageCountCacheStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + const messageCount = toNonNegativeInt(source.messageCount) + if (updatedAt === undefined || messageCount === undefined) return null + return { + updatedAt, + messageCount + } +} + +export class GroupMyMessageCountCacheService { + private readonly cacheFilePath: string + private store: GroupMyMessageCountCacheStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'group-my-message-counts.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { + if (!scopeValue || typeof scopeValue !== 'object') continue + const normalizedScope: GroupMyMessageCountScopeMap = {} + for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record)) { + const entry = normalizeEntry(entryRaw) + if (!entry) continue + normalizedScope[chatroomId] = entry + } + if (Object.keys(normalizedScope).length > 0) { + scopes[scopeKey] = normalizedScope + } + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('GroupMyMessageCountCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined { + if (!scopeKey || !chatroomId) return undefined + const scope = this.store.scopes[scopeKey] + if (!scope) return undefined + const entry = normalizeEntry(scope[chatroomId]) + if (!entry) { + delete scope[chatroomId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + return undefined + } + return entry + } + + set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void { + if (!scopeKey || !chatroomId) return + const normalized = normalizeEntry(entry) + if (!normalized) return + + if (!this.store.scopes[scopeKey]) { + this.store.scopes[scopeKey] = {} + } + + const existing = this.store.scopes[scopeKey][chatroomId] + if (existing && existing.updatedAt > normalized.updatedAt) { + return + } + + this.store.scopes[scopeKey][chatroomId] = normalized + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + delete(scopeKey: string, chatroomId: string): void { + if (!scopeKey || !chatroomId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(chatroomId in scope)) return + delete scope[chatroomId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('GroupMyMessageCountCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + const entries = Object.entries(scope) + if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + const trimmed: GroupMyMessageCountScopeMap = {} + for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) { + trimmed[chatroomId] = entry + } + this.store.scopes[scopeKey] = trimmed + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + scopeEntries.sort((a, b) => { + const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) + const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) + return bUpdatedAt - aUpdatedAt + }) + + const trimmedScopes: Record = {} + for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { + trimmedScopes[scopeKey] = scopeMap + } + this.store.scopes = trimmedScopes + } + + private persist(): void { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('GroupMyMessageCountCacheService: 保存缓存失败', error) + } + } +} diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 3168f1c..376f7ec 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -509,6 +509,58 @@ export class KeyService { return false } + private isLoginRelatedText(value: string): boolean { + const normalized = String(value || '').replace(/\s+/g, '').toLowerCase() + if (!normalized) return false + const keywords = [ + '登录', + '扫码', + '二维码', + '请在手机上确认', + '手机确认', + '切换账号', + 'wechatlogin', + 'qrcode', + 'scan' + ] + return keywords.some((keyword) => normalized.includes(keyword)) + } + + private async detectWeChatLoginRequired(pid: number): Promise { + if (!this.ensureUser32()) return false + let loginRequired = false + + const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => { + if (!this.IsWindowVisible(hWnd)) return true + const title = this.getWindowTitle(hWnd) + if (!this.isWeChatWindowTitle(title)) return true + + const pidBuf = Buffer.alloc(4) + this.GetWindowThreadProcessId(hWnd, pidBuf) + const windowPid = pidBuf.readUInt32LE(0) + if (windowPid !== pid) return true + + if (this.isLoginRelatedText(title)) { + loginRequired = true + return false + } + + const children = this.collectChildWindowInfos(hWnd) + for (const child of children) { + if (this.isLoginRelatedText(child.title) || this.isLoginRelatedText(child.className)) { + loginRequired = true + return false + } + } + return true + }, this.WNDENUMPROC_PTR) + + this.EnumWindows(enumWindowsCallback, 0) + this.koffi.unregister(enumWindowsCallback) + + return loginRequired + } + private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise { if (!this.ensureUser32()) return true const startTime = Date.now() @@ -605,6 +657,7 @@ export class KeyService { const keyBuffer = Buffer.alloc(128) const start = Date.now() + let loginRequiredDetected = false try { while (Date.now() - start < timeoutMs) { @@ -624,6 +677,9 @@ export class KeyService { const level = levelOut[0] ?? 0 if (msg) { logs.push(msg) + if (this.isLoginRelatedText(msg)) { + loginRequiredDetected = true + } onStatus?.(msg, level) } } @@ -635,6 +691,15 @@ export class KeyService { } catch { } } + const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid) + if (loginRequired) { + return { + success: false, + error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。', + logs + } + } + return { success: false, error: '获取密钥超时', logs } } @@ -731,4 +796,256 @@ export class KeyService { aesKey } } -} \ No newline at end of file + + // --- 内存扫描备选方案(融合 Dart+Python 优点)--- + // 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式 + // 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式 + + async autoGetImageKeyByMemoryScan( + userDir: string, + onProgress?: (message: string) => void + ): Promise { + if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } + + try { + // 1. 查找模板文件获取密文和 XOR 密钥 + onProgress?.('正在查找模板文件...') + const { ciphertext, xorKey } = await this._findTemplateData(userDir) + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + + onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const pid = await this.findWeChatPid() + if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } + + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + + // 3. 持续轮询内存扫描,最多 60 秒 + const deadline = Date.now() + 60_000 + let scanCount = 0 + while (Date.now() < deadline) { + scanCount++ + onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) + if (aesKey) { + onProgress?.('密钥获取成功') + return { success: true, xorKey: xorKey ?? 0, aesKey } + } + // 等 5 秒再试 + await new Promise(r => setTimeout(r, 5000)) + } + + return { + success: false, + error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。' + } + } catch (e) { + return { success: false, error: `内存扫描失败: ${e}` } + } + } + + private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const { readdirSync, readFileSync, statSync } = await import('fs') + const { join } = await import('path') + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + // 递归收集 *_t.dat 文件 + const collect = (dir: string, results: string[], limit = 32) => { + if (results.length >= limit) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= limit) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, limit) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { /* 忽略无权限目录 */ } + } + + const files: string[] = [] + collect(userDir, files) + + // 按修改时间降序 + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + // 统计末尾两字节用于 XOR 密钥 + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + // 提取密文(取第一个有效的) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { /* 忽略 */ } + } + + // 计算 XOR 密钥 + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k } + } + + return { ciphertext, xorKey } + } + + private async _scanMemoryForAesKey( + pid: number, + ciphertext: Buffer, + onProgress?: (msg: string) => void + ): Promise { + if (!this.ensureKernel32()) return null + + // 直接用已加载的 kernel32 实例,用 uintptr 传地址 + const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t']) + const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')]) + + // RW 保护标志(只扫可写区域,速度更快) + const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + const MEM_COMMIT = 0x1000 + const PAGE_NOACCESS = 0x01 + const PAGE_GUARD = 0x100 + const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64 + + const hProcess = this.OpenProcess(0x1F0FFF, false, pid) + if (!hProcess) return null + + try { + // 枚举 RW 内存区域 + const regions: Array<[number, number]> = [] + let addr = 0 + const mbi = Buffer.alloc(MBI_SIZE) + + while (addr < 0x7FFFFFFFFFFF) { + const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE) + if (ret === 0) break + // MEMORY_BASIC_INFORMATION x64 布局: + // 0: BaseAddress (8) + // 8: AllocationBase (8) + // 16: AllocationProtect (4) + 4 padding + // 24: RegionSize (8) + // 32: State (4) + // 36: Protect (4) + // 40: Type (4) + 4 padding = 48 total + const base = Number(mbi.readBigUInt64LE(0)) + const size = Number(mbi.readBigUInt64LE(24)) + const state = mbi.readUInt32LE(32) + const protect = mbi.readUInt32LE(36) + + if (state === MEM_COMMIT && + protect !== PAGE_NOACCESS && + (protect & PAGE_GUARD) === 0 && + (protect & RW_FLAGS) !== 0 && + size <= 50 * 1024 * 1024) { + regions.push([base, size]) + } + const next = base + size + if (next <= addr) break + addr = next + } + + const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024 + onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) + + const CHUNK = 4 * 1024 * 1024 + const OVERLAP = 65 + + for (let i = 0; i < regions.length; i++) { + const [base, size] = regions[i] + if (i % 20 === 0) { + onProgress?.(`扫描进度 ${i}/${regions.length}...`) + await new Promise(r => setTimeout(r, 1)) // 让出事件循环 + } + + let offset = 0 + let trailing: Buffer | null = null + + while (offset < size) { + const chunkSize = Math.min(CHUNK, size - offset) + const buf = Buffer.alloc(chunkSize) + const bytesReadOut = [0] + const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut) + if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue } + + const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0]) + + // 搜索 ASCII 32字节密钥 + const key = this._searchAsciiKey(data, ciphertext) + if (key) { this.CloseHandle(hProcess); return key } + + // 搜索 UTF-16LE 32字节密钥 + const key16 = this._searchUtf16Key(data, ciphertext) + if (key16) { this.CloseHandle(hProcess); return key16 } + + trailing = data.subarray(Math.max(0, data.length - OVERLAP)) + offset += chunkSize + } + } + + return null + } finally { + this.CloseHandle(hProcess) + } + } + + private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 34; i++) { + if (this._isAlphaNum(data[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this._isAlphaNum(data[i + j])) { valid = false; break } + } + if (!valid) continue + if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue + const keyBytes = data.subarray(i + 1, i + 33) + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 65; i++) { + let valid = true + for (let j = 0; j < 32; j++) { + if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } + } + if (!valid) continue + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _isAlphaNum(b: number): boolean { + return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) + } + + private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + // 支持 JPEG / PNG / WEBP / WXGF / GIF + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { return false } + } +} diff --git a/electron/services/sessionStatsCacheService.ts b/electron/services/sessionStatsCacheService.ts new file mode 100644 index 0000000..147930d --- /dev/null +++ b/electron/services/sessionStatsCacheService.ts @@ -0,0 +1,293 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 2 +const MAX_SESSION_ENTRIES_PER_SCOPE = 2000 +const MAX_SCOPE_ENTRIES = 12 + +export interface SessionStatsCacheStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + transferMessages: number + redPacketMessages: number + callMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +export interface SessionStatsCacheEntry { + updatedAt: number + includeRelations: boolean + stats: SessionStatsCacheStats +} + +interface SessionStatsScopeMap { + [sessionId: string]: SessionStatsCacheEntry +} + +interface SessionStatsCacheStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function normalizeStats(raw: unknown): SessionStatsCacheStats | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + + const totalMessages = toNonNegativeInt(source.totalMessages) + const voiceMessages = toNonNegativeInt(source.voiceMessages) + const imageMessages = toNonNegativeInt(source.imageMessages) + const videoMessages = toNonNegativeInt(source.videoMessages) + const emojiMessages = toNonNegativeInt(source.emojiMessages) + const transferMessages = toNonNegativeInt(source.transferMessages) + const redPacketMessages = toNonNegativeInt(source.redPacketMessages) + const callMessages = toNonNegativeInt(source.callMessages) + + if ( + totalMessages === undefined || + voiceMessages === undefined || + imageMessages === undefined || + videoMessages === undefined || + emojiMessages === undefined || + transferMessages === undefined || + redPacketMessages === undefined || + callMessages === undefined + ) { + return null + } + + const normalized: SessionStatsCacheStats = { + totalMessages, + voiceMessages, + imageMessages, + videoMessages, + emojiMessages, + transferMessages, + redPacketMessages, + callMessages + } + + const firstTimestamp = toNonNegativeInt(source.firstTimestamp) + if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp + + const lastTimestamp = toNonNegativeInt(source.lastTimestamp) + if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp + + const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups) + if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups + + const groupMemberCount = toNonNegativeInt(source.groupMemberCount) + if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount + + const groupMyMessages = toNonNegativeInt(source.groupMyMessages) + if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages + + const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers) + if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers + + const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends) + if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends + + return normalized +} + +function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false + const stats = normalizeStats(source.stats) + + if (updatedAt === undefined || !stats) { + return null + } + + return { + updatedAt, + includeRelations, + stats + } +} + +export class SessionStatsCacheService { + private readonly cacheFilePath: string + private store: SessionStatsCacheStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'session-stats.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const version = Number(payload.version) + if (!Number.isFinite(version) || version !== CACHE_VERSION) { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { + if (!scopeValue || typeof scopeValue !== 'object') continue + const normalizedScope: SessionStatsScopeMap = {} + for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record)) { + const entry = normalizeEntry(entryRaw) + if (!entry) continue + normalizedScope[sessionId] = entry + } + if (Object.keys(normalizedScope).length > 0) { + scopes[scopeKey] = normalizedScope + } + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('SessionStatsCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined { + if (!scopeKey || !sessionId) return undefined + const scope = this.store.scopes[scopeKey] + if (!scope) return undefined + const entry = normalizeEntry(scope[sessionId]) + if (!entry) { + delete scope[sessionId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + return undefined + } + return entry + } + + set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void { + if (!scopeKey || !sessionId) return + const normalized = normalizeEntry(entry) + if (!normalized) return + + if (!this.store.scopes[scopeKey]) { + this.store.scopes[scopeKey] = {} + } + this.store.scopes[scopeKey][sessionId] = normalized + + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + delete(scopeKey: string, sessionId: string): void { + if (!scopeKey || !sessionId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(sessionId in scope)) return + + delete scope[sessionId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('SessionStatsCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + const entries = Object.entries(scope) + if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return + + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + const trimmed: SessionStatsScopeMap = {} + for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) { + trimmed[sessionId] = entry + } + this.store.scopes[scopeKey] = trimmed + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + + scopeEntries.sort((a, b) => { + const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) + const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) + return bUpdatedAt - aUpdatedAt + }) + + const trimmedScopes: Record = {} + for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { + trimmedScopes[scopeKey] = scopeMap + } + this.store.scopes = trimmedScopes + } + + private persist(): void { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('SessionStatsCacheService: 保存缓存失败', error) + } + } +} diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 835850f..1e0be35 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -44,6 +44,68 @@ export interface SnsPost { linkUrl?: string } +interface SnsContactIdentity { + username: string + wxid: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string +} + +interface ParsedLikeUser { + username?: string + nickname?: string +} + +interface ParsedCommentItem { + id: string + nickname: string + username?: string + content: string + refCommentId: string + refUsername?: string + refNickname?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] +} + +interface ArkmeLikeDetail { + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + source: 'xml' | 'legacy' +} + +interface ArkmeCommentDetail { + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] + source: 'xml' | 'legacy' +} + const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { @@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => { /** * 从 XML 中解析评论信息(含表情包、回复关系) */ -function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] { +function parseCommentsFromXml(xml: string): ParsedCommentItem[] { if (!xml) return [] type CommentItem = { @@ -229,12 +291,262 @@ class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() + private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null + private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private lastTimelineFallbackAt = 0 + private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 constructor() { this.configService = new ConfigService() this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private toOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + private async resolveContactIdentity( + username: string, + identityCache: Map> + ): Promise { + const normalized = String(username || '').trim() + if (!normalized) return null + + let pending = identityCache.get(normalized) + if (!pending) { + pending = (async () => { + const cached = this.contactCache.get(normalized) + let alias: string | undefined + let remark: string | undefined + let nickName: string | undefined + + try { + const contactResult = await wcdbService.getContact(normalized) + if (contactResult.success && contactResult.contact) { + const contact = contactResult.contact + alias = this.toOptionalString(contact.alias ?? contact.Alias) + remark = this.toOptionalString(contact.remark ?? contact.Remark) + nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName) + } + } catch { + // 联系人补全失败不影响导出 + } + + const displayName = remark || nickName || alias || cached?.displayName || normalized + return { + username: normalized, + wxid: normalized, + alias, + wechatId: alias, + remark, + nickName, + displayName + } + })() + identityCache.set(normalized, pending) + } + + return pending + } + + private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] { + if (!xml) return [] + const likes: ParsedLikeUser[] = [] + try { + let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) + if (!likeListMatch) return likes + + const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi + let m: RegExpExecArray | null + while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { + const block = m[1] + const username = this.toOptionalString(block.match(/([^<]*)<\/username>/i)?.[1]) + const nickname = this.toOptionalString( + block.match(/([^<]*)<\/nickname>/i)?.[1] + || block.match(/([^<]*)<\/nickName>/i)?.[1] + ) + if (username || nickname) { + likes.push({ username, nickname }) + } + } + } catch (e) { + console.error('[SnsService] 解析点赞用户失败:', e) + } + return likes + } + + private async buildArkmeInteractionDetails( + post: SnsPost, + identityCache: Map> + ): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> { + const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '') + const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0 + ? xmlLikes + : (post.likes || []).map((nickname) => ({ nickname })) + const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy' + const likesDetail: ArkmeLikeDetail[] = [] + const likeSeen = new Set() + + for (const like of likeCandidates) { + const identity = like.username + ? await this.resolveContactIdentity(like.username, identityCache) + : null + const nickname = like.nickname || identity?.displayName || like.username || '' + const username = identity?.username || like.username + const key = `${username || ''}|${nickname}` + if (likeSeen.has(key)) continue + likeSeen.add(key) + likesDetail.push({ + nickname, + username, + wxid: username, + alias: identity?.alias, + wechatId: identity?.wechatId, + remark: identity?.remark, + nickName: identity?.nickName, + displayName: identity?.displayName || nickname || username || '', + source: likeSource + }) + } + + const xmlComments = parseCommentsFromXml(post.rawXml || '') + const commentMap = new Map() + for (const comment of post.comments || []) { + if (comment.id) commentMap.set(comment.id, comment) + } + + const commentsBase: ParsedCommentItem[] = xmlComments.length > 0 + ? xmlComments.map((comment) => { + const fallback = comment.id ? commentMap.get(comment.id) : undefined + return { + id: comment.id || fallback?.id || '', + nickname: comment.nickname || fallback?.nickname || '', + username: comment.username, + content: comment.content || fallback?.content || '', + refCommentId: comment.refCommentId || fallback?.refCommentId || '', + refUsername: comment.refUsername, + refNickname: comment.refNickname || fallback?.refNickname, + emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis + } + }) + : (post.comments || []).map((comment) => ({ + id: comment.id || '', + nickname: comment.nickname || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname, + emojis: comment.emojis + })) + + if (xmlComments.length > 0) { + const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean)) + for (const comment of post.comments || []) { + if (comment.id && mappedIds.has(comment.id)) continue + commentsBase.push({ + id: comment.id || '', + nickname: comment.nickname || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname, + emojis: comment.emojis + }) + } + } + + const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy' + const commentsDetail: ArkmeCommentDetail[] = [] + + for (const comment of commentsBase) { + const actor = comment.username + ? await this.resolveContactIdentity(comment.username, identityCache) + : null + const refActor = comment.refUsername + ? await this.resolveContactIdentity(comment.refUsername, identityCache) + : null + const nickname = comment.nickname || actor?.displayName || comment.username || '' + const username = actor?.username || comment.username + const refUsername = refActor?.username || comment.refUsername + commentsDetail.push({ + id: comment.id || '', + nickname, + username, + wxid: username, + alias: actor?.alias, + wechatId: actor?.wechatId, + remark: actor?.remark, + nickName: actor?.nickName, + displayName: actor?.displayName || nickname || username || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname || refActor?.displayName, + refUsername, + refWxid: refUsername, + refAlias: refActor?.alias, + refWechatId: refActor?.wechatId, + refRemark: refActor?.remark, + refNickName: refActor?.nickName, + refDisplayName: refActor?.displayName, + emojis: comment.emojis, + source: commentSource + }) + } + + return { likesDetail, commentsDetail } + } + + private parseCountValue(row: any): number { + if (!row || typeof row !== 'object') return 0 + const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] + const num = Number(raw) + return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 + } + + private pickTimelineUsername(post: any): string { + const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' + if (typeof raw !== 'string') return '' + return raw.trim() + } + + private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { + const pageSize = 500 + const uniqueUsers = new Set() + let totalPosts = 0 + let myPosts = 0 + let offset = 0 + const normalizedMyWxid = this.toOptionalString(myWxid) + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈统计失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + totalPosts += rows.length + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1 + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return { + totalPosts, + totalFriends: uniqueUsers.size, + myPosts: normalizedMyWxid ? myPosts : null + } + } + private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] @@ -349,14 +661,207 @@ class SnsService { } async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine') - if (!result.success || !result.rows) { - // 尝试 userName 列名 - const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine') - if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error } - return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) } + 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 } - return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } + + 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 || '获取朋友圈联系人失败' } + } + + 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]) + } + } + } + + return { totalPosts, totalFriends, myPosts } + } + + async getExportStats(options?: { + allowTimelineFallback?: boolean + preferCache?: boolean + }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + const allowTimelineFallback = options?.allowTimelineFallback ?? true + const preferCache = options?.preferCache ?? false + const now = Date.now() + const myWxid = this.toOptionalString(this.configService.get('myWxid')) + + try { + if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends, + myPosts: this.exportStatsCache.myPosts + } + } + } + + let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid) + let fallbackAttempted = false + let fallbackError = '' + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 + if ( + allowTimelineFallback && + (totalPosts <= 0 || totalFriends <= 0) && + now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs + ) { + fallbackAttempted = true + try { + const timelineStats = await this.getExportStatsFromTimeline(myWxid) + this.lastTimelineFallbackAt = Date.now() + if (timelineStats.totalPosts > 0) { + totalPosts = timelineStats.totalPosts + } + if (timelineStats.totalFriends > 0) { + totalFriends = timelineStats.totalFriends + } + if (timelineStats.myPosts !== null) { + myPosts = timelineStats.myPosts + } + } catch (error) { + fallbackError = String(error) + console.error('[SnsService] getExportStats timeline fallback failed:', error) + } + } + + const normalizedStats = { + totalPosts: Math.max(0, Number(totalPosts || 0)), + totalFriends: Math.max(0, Number(totalFriends || 0)), + myPosts: myWxid + ? (myPosts === null ? null : Math.max(0, Number(myPosts || 0))) + : null + } + const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0 + const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0) + + // 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。 + if (!computedHasData && cacheHasData && this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends, + myPosts: this.exportStatsCache.myPosts + } + } + } + + // 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0)。 + if (!computedHasData && fallbackAttempted && fallbackError) { + return { success: false, error: fallbackError } + } + + this.exportStatsCache = { + totalPosts: normalizedStats.totalPosts, + totalFriends: normalizedStats.totalFriends, + myPosts: normalizedStats.myPosts, + updatedAt: Date.now() + } + + return { success: true, data: normalizedStats } + } catch (e) { + if (this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends, + myPosts: this.exportStatsCache.myPosts + } + } + } + return { success: false, error: String(e) } + } + } + + async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + return this.getExportStats({ + allowTimelineFallback: false, + preferCache: true + }) } // 安装朋友圈删除拦截 @@ -431,20 +936,6 @@ class SnsService { const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) if (!result.success || !result.timeline || result.timeline.length === 0) return result - // 诊断:测试 execQuery 查 content 字段 - try { - const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1') - if (testResult.success && testResult.rows?.[0]) { - const r = testResult.rows[0] - console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200)) - console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList')) - } else { - console.log('[SnsService] execQuery 诊断失败:', testResult.error) - } - } catch (e) { - console.log('[SnsService] execQuery 诊断异常:', e) - } - const enrichedTimeline = result.timeline.map((post: any) => { const contact = this.contactCache.get(post.username) const isVideoPost = post.type === 15 @@ -577,14 +1068,44 @@ class SnsService { */ async exportTimeline(options: { outputDir: string - format: 'json' | 'html' + format: 'json' | 'html' | 'arkmejson' usernames?: string[] keyword?: string exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean startTime?: number endTime?: number - }, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> { - const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options + }, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: { + shouldPause?: () => boolean + shouldStop?: () => boolean + }): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> { + const { outputDir, format, usernames, keyword, startTime, endTime } = options + const hasExplicitMediaSelection = + typeof options.exportImages === 'boolean' || + typeof options.exportLivePhotos === 'boolean' || + typeof options.exportVideos === 'boolean' + const shouldExportImages = hasExplicitMediaSelection + ? options.exportImages === true + : options.exportMedia === true + const shouldExportLivePhotos = hasExplicitMediaSelection + ? options.exportLivePhotos === true + : options.exportMedia === true + const shouldExportVideos = hasExplicitMediaSelection + ? options.exportVideos === true + : options.exportMedia === true + const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos + const getControlState = (): 'paused' | 'stopped' | null => { + if (control?.shouldStop?.()) return 'stopped' + if (control?.shouldPause?.()) return 'paused' + return null + } + const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => ( + state === 'stopped' + ? { success: true, stopped: true, filePath: '', postCount, mediaCount } + : { success: true, paused: true, filePath: '', postCount, mediaCount } + ) try { // 确保输出目录存在 @@ -601,6 +1122,10 @@ class SnsService { progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' }) while (hasMore) { + const controlState = getControlState() + if (controlState) { + return buildInterruptedResult(controlState, allPosts.length, 0) + } const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs) if (result.success && result.timeline && result.timeline.length > 0) { allPosts.push(...result.timeline) @@ -628,15 +1153,54 @@ class SnsService { let mediaCount = 0 const mediaDir = join(outputDir, 'media') - if (exportMedia) { + if (shouldExportMedia) { if (!existsSync(mediaDir)) { mkdirSync(mediaDir, { recursive: true }) } // 收集所有媒体下载任务 - const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = [] + const mediaTasks: Array<{ + kind: 'image' | 'video' | 'livephoto' + media: SnsMedia + url: string + key?: string + postId: string + mi: number + }> = [] for (const post of allPosts) { - post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi })) + post.media.forEach((media, mi) => { + const isVideo = isVideoUrl(media.url) + if (shouldExportImages && !isVideo && media.url) { + mediaTasks.push({ + kind: 'image', + media, + url: media.url, + key: media.key, + postId: post.id, + mi + }) + } + if (shouldExportVideos && isVideo && media.url) { + mediaTasks.push({ + kind: 'video', + media, + url: media.url, + key: media.key, + postId: post.id, + mi + }) + } + if (shouldExportLivePhotos && media.livePhoto?.url) { + mediaTasks.push({ + kind: 'livephoto', + media, + url: media.livePhoto.url, + key: media.livePhoto.key || media.key, + postId: post.id, + mi + }) + } + }) } // 并发下载(5路) @@ -645,29 +1209,42 @@ class SnsService { const runTask = async (task: typeof mediaTasks[0]) => { const { media, postId, mi } = task try { - const isVideo = isVideoUrl(media.url) + const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url) const ext = isVideo ? 'mp4' : 'jpg' - const fileName = `${postId}_${mi}.${ext}` + const suffix = task.kind === 'livephoto' ? '_live' : '' + const fileName = `${postId}_${mi}${suffix}.${ext}` const filePath = join(mediaDir, fileName) if (existsSync(filePath)) { - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } else { - const result = await this.fetchAndDecryptImage(media.url, media.key) + const result = await this.fetchAndDecryptImage(task.url, task.key) if (result.success && result.data) { await writeFile(filePath, result.data) - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } else if (result.success && result.cachePath) { const cachedData = await readFile(result.cachePath) await writeFile(filePath, cachedData) - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } } } catch (e) { - console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e) + console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e) } done++ progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) @@ -677,11 +1254,18 @@ class SnsService { const queue = [...mediaTasks] const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { while (queue.length > 0) { + const controlState = getControlState() + if (controlState) return controlState const task = queue.shift()! await runTask(task) } + return null }) - await Promise.all(workers) + const workerResults = await Promise.all(workers) + const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped') + if (interruptedState) { + return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) + } } // 2.5 下载头像 @@ -693,6 +1277,8 @@ class SnsService { const avatarQueue = [...uniqueUsers] const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => { while (avatarQueue.length > 0) { + const controlState = getControlState() + if (controlState) return controlState const post = avatarQueue.shift()! try { const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg` @@ -710,11 +1296,20 @@ class SnsService { avatarDone++ progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` }) } + return null }) - await Promise.all(avatarWorkers) + const avatarWorkerResults = await Promise.all(avatarWorkers) + const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped') + if (interruptedState) { + return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) + } } // 3. 生成输出文件 + const finalControlState = getControlState() + if (finalControlState) { + return buildInterruptedResult(finalControlState, allPosts.length, mediaCount) + } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) let outputFilePath: string @@ -747,6 +1342,92 @@ class SnsService { })) } await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') + } else if (format === 'arkmejson') { + outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) + progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' }) + + const identityCache = new Map>() + const posts: any[] = [] + let built = 0 + + for (const post of allPosts) { + const controlState = getControlState() + if (controlState) { + return buildInterruptedResult(controlState, allPosts.length, mediaCount) + } + + const authorIdentity = await this.resolveContactIdentity(post.username, identityCache) + const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache) + + posts.push({ + id: post.id, + username: post.username, + nickname: post.nickname, + author: authorIdentity + ? { + ...authorIdentity + } + : { + username: post.username, + wxid: post.username, + displayName: post.nickname || post.username + }, + createTime: post.createTime, + createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'), + contentDesc: post.contentDesc, + type: post.type, + media: post.media.map(m => ({ + url: m.url, + thumb: m.thumb, + localPath: (m as any).localPath || undefined, + livePhoto: m.livePhoto ? { + url: m.livePhoto.url, + thumb: m.livePhoto.thumb, + localPath: (m.livePhoto as any).localPath || undefined + } : undefined + })), + likes: post.likes, + comments: post.comments, + likesDetail, + commentsDetail, + linkTitle: (post as any).linkTitle, + linkUrl: (post as any).linkUrl + }) + + built++ + if (built % 20 === 0 || built === allPosts.length) { + progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` }) + } + } + + const ownerWxid = this.toOptionalString(this.configService.get('myWxid')) + const ownerIdentity = ownerWxid + ? await this.resolveContactIdentity(ownerWxid, identityCache) + : null + const recordOwner = ownerIdentity + ? { ...ownerIdentity } + : ownerWxid + ? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid } + : { username: '', wxid: '', displayName: '' } + + const exportData = { + exportTime: new Date().toISOString(), + format: 'arkmejson', + schemaVersion: '1.0.0', + recordOwner, + mediaSelection: { + images: shouldExportImages, + livePhotos: shouldExportLivePhotos, + videos: shouldExportVideos + }, + totalPosts: allPosts.length, + filters: { + usernames: usernames || [], + keyword: keyword || '' + }, + posts + } + await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') } else { // HTML 格式 outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 1c47b4c..5153298 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,4 +1,4 @@ -import { join, dirname, basename } from 'path' +import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' // DLL 初始化错误信息,用于帮助用户诊断问题 @@ -114,6 +114,9 @@ export class WcdbCore { private wcdbStartMonitorPipe: any = null private wcdbStopMonitorPipe: any = null private wcdbGetMonitorPipeName: any = null + private wcdbCloudInit: any = null + private wcdbCloudReport: any = null + private wcdbCloudStop: any = null private monitorPipeClient: any = null private monitorCallback: ((type: string, json: string) => void) | null = null @@ -702,6 +705,26 @@ export class WcdbCore { this.wcdbVerifyUser = null } + // wcdb_status wcdb_cloud_init(int32_t interval_seconds) + try { + this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)') + } catch { + this.wcdbCloudInit = null + } + + // wcdb_status wcdb_cloud_report(const char* stats_json) + try { + this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)') + } catch { + this.wcdbCloudReport = null + } + + // void wcdb_cloud_stop() + try { + this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()') + } catch { + this.wcdbCloudStop = null + } // 初始化 @@ -1144,6 +1167,40 @@ export class WcdbCore { } } + async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: 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, counts: {} } + } + + try { + const counts: Record = {} + for (let i = 0; i < normalizedSessionIds.length; i += 1) { + const sessionId = normalizedSessionIds[i] + const outCount = [0] + const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) + counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0 + + if (i > 0 && i % 160 === 0) { + await new Promise(resolve => setImmediate(resolve)) + } + } + return { success: true, counts } + } 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 未连接' } @@ -1841,8 +1898,57 @@ export class WcdbCore { } /** - * 验证 Windows Hello + * 数据收集初始化 */ + async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return { success: false, error: 'WCDB init failed' } + } + if (!this.wcdbCloudInit) { + return { success: false, error: 'Cloud init API not supported by DLL' } + } + try { + const result = this.wcdbCloudInit(intervalSeconds) + if (result !== 0) { + return { success: false, error: `Cloud init failed: ${result}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return { success: false, error: 'WCDB init failed' } + } + if (!this.wcdbCloudReport) { + return { success: false, error: 'Cloud report API not supported by DLL' } + } + try { + const result = this.wcdbCloudReport(statsJson || '') + if (result !== 0) { + return { success: false, error: `Cloud report failed: ${result}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + cloudStop(): { success: boolean; error?: string } { + if (!this.wcdbCloudStop) { + return { success: false, error: 'Cloud stop API not supported by DLL' } + } + try { + this.wcdbCloudStop() + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> { if (!this.initialized) { const initOk = await this.initialize() @@ -2093,4 +2199,3 @@ export class WcdbCore { }) } } - diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index b8834f6..6aee8e9 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -218,6 +218,10 @@ export class WcdbService { return this.callWorker('getMessageCount', { sessionId }) } + async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getMessageCounts', { sessionIds }) + } + /** * 获取联系人昵称 */ @@ -479,6 +483,27 @@ export class WcdbService { return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint }) } + /** + * 数据收集:初始化 + */ + async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> { + return this.callWorker('cloudInit', { intervalSeconds }) + } + + /** + * 数据收集:上报数据 + */ + async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('cloudReport', { statsJson }) + } + + /** + * 数据收集:停止 + */ + cloudStop(): Promise<{ success: boolean; error?: string }> { + return this.callWorker('cloudStop', {}) + } + } diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index d95f5f6..333527a 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -54,6 +54,9 @@ if (parentPort) { case 'getMessageCount': result = await core.getMessageCount(payload.sessionId) break + case 'getMessageCounts': + result = await core.getMessageCounts(payload.sessionIds) + break case 'getDisplayNames': result = await core.getDisplayNames(payload.usernames) break @@ -171,7 +174,15 @@ if (parentPort) { case 'deleteMessage': result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint) break - + case 'cloudInit': + result = await core.cloudInit(payload.intervalSeconds) + break + case 'cloudReport': + result = await core.cloudReport(payload.statsJson) + break + case 'cloudStop': + result = core.cloudStop() + break default: result = { success: false, error: `Unknown method: ${type}` } } diff --git a/package.json b/package.json index 5e35e9e..514d99d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "postinstall": "electron-builder install-app-deps", "rebuild": "electron-rebuild", "dev": "vite", + "typecheck": "tsc --noEmit", "build": "tsc && vite build && electron-builder", "preview": "vite preview", "electron:dev": "vite --mode electron", diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 4dcca7d..c03efbe 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wx_key.dll b/resources/wx_key.dll index 30ddb52..5edf298 100644 Binary files a/resources/wx_key.dll and b/resources/wx_key.dll differ diff --git a/src/App.scss b/src/App.scss index 3c137bd..5cffbbd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -69,6 +69,19 @@ flex: 1; overflow: auto; padding: 24px; + position: relative; +} + +.export-keepalive-page { + height: 100%; + + &.hidden { + display: none; + } +} + +.export-route-anchor { + display: none; } @keyframes appFadeIn { diff --git a/src/App.tsx b/src/App.tsx index c999a80..9d040d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import NotificationWindow from './pages/NotificationWindow' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' import * as configService from './services/config' +import * as cloudControl from './services/cloudControl' import { Download, X, Shield } from 'lucide-react' import './App.scss' @@ -60,7 +61,9 @@ function App() { const isOnboardingWindow = location.pathname === '/onboarding-window' const isVideoPlayerWindow = location.pathname === '/video-player-window' const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') + const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' + const isExportRoute = location.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) // 锁定状态 @@ -75,6 +78,9 @@ function App() { const [agreementChecked, setAgreementChecked] = useState(false) const [agreementLoading, setAgreementLoading] = useState(true) + // 数据收集同意状态 + const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) + useEffect(() => { const root = document.documentElement const body = document.body @@ -170,6 +176,12 @@ function App() { const agreed = await configService.getAgreementAccepted() if (!agreed) { setShowAgreement(true) + } else { + // 协议已同意,检查数据收集同意状态 + const consent = await configService.getAnalyticsConsent() + if (consent === null) { + setShowAnalyticsConsent(true) + } } } catch (e) { console.error('检查协议状态失败:', e) @@ -180,16 +192,44 @@ function App() { checkAgreement() }, []) + // 初始化数据收集 + useEffect(() => { + cloudControl.initCloudControl() + }, []) + + // 记录页面访问 + useEffect(() => { + const path = location.pathname + if (path && path !== '/') { + cloudControl.recordPage(path) + } + }, [location.pathname]) + const handleAgree = async () => { if (!agreementChecked) return await configService.setAgreementAccepted(true) setShowAgreement(false) + // 协议同意后,检查数据收集同意 + const consent = await configService.getAnalyticsConsent() + if (consent === null) { + setShowAnalyticsConsent(true) + } } const handleDisagree = () => { window.electronAPI.window.close() } + const handleAnalyticsAllow = async () => { + await configService.setAnalyticsConsent(true) + setShowAnalyticsConsent(false) + } + + const handleAnalyticsDeny = async () => { + await configService.setAnalyticsConsent(false) + window.electronAPI.window.close() + } + // 监听启动时的更新通知 useEffect(() => { if (isNotificationWindow) return // Skip updates in notification window @@ -360,6 +400,12 @@ function App() { return } + // 独立会话聊天窗口(仅显示聊天内容区域) + if (isStandaloneChatWindow) { + const sessionId = new URLSearchParams(location.search).get('sessionId') || '' + return + } + // 独立通知窗口 if (isNotificationWindow) { return @@ -439,6 +485,42 @@ function App() { )} + {/* 数据收集同意弹窗 */} + {showAnalyticsConsent && !agreementLoading && ( +
+
+
+ +

使用数据收集说明

+
+
+
+

为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。

+ +

我们会收集什么?

+

• 功能使用情况(如哪些功能被使用、使用频率)

+

• 应用性能数据(如加载时间、错误日志)

+

• 设备基本信息(如操作系统版本、应用版本)

+ +

我们不会收集什么?

+

• 你的聊天记录内容

+

• 个人身份信息

+

• 联系人信息

+

• 任何可以识别你身份的数据

+

• 一切你担心会涉及隐藏的数据

+ +
+
+
+
+ + +
+
+
+
+ )} + {/* 更新提示对话框 */}
+
+ +
+ } /> } /> @@ -468,7 +554,7 @@ function App() { } /> } /> - } /> +