#849 以及导出中媒体相关问题修复

This commit is contained in:
cc
2026-04-26 18:46:56 +08:00
parent 1976edc483
commit a86a51c30c
10 changed files with 761 additions and 188 deletions

View File

@@ -1,14 +1,18 @@
import { parentPort, workerData } from 'worker_threads' import { parentPort, workerData } from 'worker_threads'
import type { ExportOptions } from './services/exportService'
interface ExportWorkerConfig { interface ExportWorkerConfig {
sessionIds: string[] mode?: 'sessions' | 'single' | 'contacts'
outputDir: string sessionIds?: string[]
options: ExportOptions sessionId?: string
outputDir?: string
outputPath?: string
options?: any
taskId?: string taskId?: string
dbPath?: string dbPath?: string
decryptKey?: string decryptKey?: string
myWxid?: string myWxid?: string
imageXorKey?: unknown
imageAesKey?: string
resourcesPath?: string resourcesPath?: string
userDataPath?: string userDataPath?: string
logEnabled?: boolean logEnabled?: boolean
@@ -20,6 +24,93 @@ const controlState = {
stopRequested: false stopRequested: false
} }
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
const CREATED_PATH_BATCH_LIMIT = 256
const PROGRESS_POST_INTERVAL_MS = 180
let queuedCreatedFiles: string[] = []
let queuedCreatedDirs: string[] = []
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
let pendingProgress: any = null
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
let lastProgressPostedAt = 0
function flushCreatedPaths() {
if (createdPathFlushTimer) {
clearTimeout(createdPathFlushTimer)
createdPathFlushTimer = null
}
const filePaths = queuedCreatedFiles
const dirPaths = queuedCreatedDirs
queuedCreatedFiles = []
queuedCreatedDirs = []
if (!parentPort) return
if (filePaths.length > 0) {
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
}
if (dirPaths.length > 0) {
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
}
}
function scheduleCreatedPathFlush() {
if (createdPathFlushTimer) return
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
}
function queueCreatedFile(filePath: string) {
const normalized = String(filePath || '').trim()
if (!normalized) return
queuedCreatedFiles.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function queueCreatedDir(dirPath: string) {
const normalized = String(dirPath || '').trim()
if (!normalized) return
queuedCreatedDirs.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function flushProgress() {
if (!pendingProgress) return
if (progressPostTimer) {
clearTimeout(progressPostTimer)
progressPostTimer = null
}
parentPort?.postMessage({
type: 'export:progress',
data: pendingProgress
})
pendingProgress = null
lastProgressPostedAt = Date.now()
}
function queueProgress(progress: any) {
pendingProgress = progress
if (progress?.phase === 'complete') {
flushProgress()
return
}
const now = Date.now()
const elapsed = now - lastProgressPostedAt
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
flushProgress()
return
}
if (progressPostTimer) return
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
}
parentPort?.on('message', (message: any) => { parentPort?.on('message', (message: any) => {
if (!message || typeof message.type !== 'string') return if (!message || typeof message.type !== 'string') return
if (message.type === 'export:pause') { if (message.type === 'export:pause') {
@@ -57,32 +148,49 @@ async function run() {
exportService.setRuntimeConfig({ exportService.setRuntimeConfig({
dbPath: config.dbPath, dbPath: config.dbPath,
decryptKey: config.decryptKey, decryptKey: config.decryptKey,
myWxid: config.myWxid myWxid: config.myWxid,
imageXorKey: config.imageXorKey,
imageAesKey: config.imageAesKey
}) })
const result = await exportService.exportSessions( const onProgress = (progress: any) => queueProgress(progress)
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''), const taskControl = config.taskId
config.options || { format: 'json' }, ? {
(progress) => { shouldPause: () => controlState.pauseRequested,
parentPort?.postMessage({ shouldStop: () => controlState.stopRequested,
type: 'export:progress', recordCreatedFile: queueCreatedFile,
data: progress recordCreatedDir: queueCreatedDir
}) }
}, : undefined
config.taskId
? { let result: any
shouldPause: () => controlState.pauseRequested, if (config.mode === 'contacts') {
shouldStop: () => controlState.stopRequested, const { contactExportService } = await import('./services/contactExportService')
recordCreatedFile: (filePath: string) => { result = await contactExportService.exportContacts(
parentPort?.postMessage({ type: 'export:createdFile', filePath }) String(config.outputDir || ''),
}, config.options || {}
recordCreatedDir: (dirPath: string) => { )
parentPort?.postMessage({ type: 'export:createdDir', dirPath }) } else if (config.mode === 'single') {
} result = await exportService.exportSessionToChatLab(
} String(config.sessionId || '').trim(),
: undefined String(config.outputPath || '').trim(),
) config.options || { format: 'chatlab' },
onProgress,
taskControl
)
} else {
result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
onProgress,
taskControl
)
}
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({ parentPort?.postMessage({
type: 'export:result', type: 'export:result',
@@ -91,6 +199,8 @@ async function run() {
} }
run().catch((error) => { run().catch((error) => {
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({ parentPort?.postMessage({
type: 'export:error', type: 'export:error',
error: String(error) error: String(error)

View File

@@ -23,7 +23,6 @@ import { KeyServiceMac } from './services/keyServiceMac'
import { voiceTranscribeService } from './services/voiceTranscribeService' import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService' import { snsService, isVideoUrl } from './services/snsService'
import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService' import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService' import { cloudControlService } from './services/cloudControlService'
@@ -3046,7 +3045,7 @@ function registerIpcHandlers() {
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
const taskId = normalizeExportTaskId(controlOptions?.taskId) const taskId = normalizeExportTaskId(controlOptions?.taskId)
const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined if (taskId) exportTaskControlService.createControl(taskId, outputDir)
if (taskId) activeExportTasks.add(taskId) if (taskId) activeExportTasks.add(taskId)
const PROGRESS_FORWARD_INTERVAL_MS = 180 const PROGRESS_FORWARD_INTERVAL_MS = 180
let pendingProgress: ExportProgress | null = null let pendingProgress: ExportProgress | null = null
@@ -3091,17 +3090,13 @@ function registerIpcHandlers() {
queueProgress(progress) queueProgress(progress)
} }
const runMainFallback = async (reason: string) => {
console.warn(`[fallback-export-main] ${reason}`)
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
}
const cfg = configService || new ConfigService() const cfg = configService || new ConfigService()
configService = cfg configService = cfg
const logEnabled = cfg.get('logEnabled') const logEnabled = cfg.get('logEnabled')
const dbPath = String(cfg.get('dbPath') || '').trim() const dbPath = String(cfg.get('dbPath') || '').trim()
const decryptKey = String(cfg.get('decryptKey') || '').trim() const decryptKey = String(cfg.get('decryptKey') || '').trim()
const myWxid = String(cfg.get('myWxid') || '').trim() const myWxid = String(cfg.get('myWxid') || '').trim()
const imageKeys = cfg.getImageKeysForCurrentWxid()
const resourcesPath = app.isPackaged const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources') ? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') : join(app.getAppPath(), 'resources')
@@ -3119,6 +3114,8 @@ function registerIpcHandlers() {
dbPath, dbPath,
decryptKey, decryptKey,
myWxid, myWxid,
imageXorKey: imageKeys.xorKey,
imageAesKey: imageKeys.aesKey,
resourcesPath, resourcesPath,
userDataPath, userDataPath,
logEnabled logEnabled
@@ -3155,6 +3152,20 @@ function registerIpcHandlers() {
onProgress(msg.data as ExportProgress) onProgress(msg.data as ExportProgress)
return return
} }
if (msg && msg.type === 'export:createdFiles' && taskId) {
const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : []
for (const filePath of filePaths) {
exportTaskControlService.recordCreatedFile(taskId, String(filePath || ''))
}
return
}
if (msg && msg.type === 'export:createdDirs' && taskId) {
const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : []
for (const dirPath of dirPaths) {
exportTaskControlService.recordCreatedDir(taskId, String(dirPath || ''))
}
return
}
if (msg && msg.type === 'export:createdFile' && taskId) { if (msg && msg.type === 'export:createdFile' && taskId) {
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || '')) exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
return return
@@ -3191,7 +3202,21 @@ function registerIpcHandlers() {
const result = await runWorker() const result = await runWorker()
return await finalizeExportTaskControlResult(taskId, result) return await finalizeExportTaskControlResult(taskId, result)
} catch (error) { } catch (error) {
const result = await runMainFallback(error instanceof Error ? error.message : String(error)) const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker] ${errorMessage}`)
const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : []
const failedSessionErrors: Record<string, string> = {}
for (const sessionId of normalizedSessionIds) {
failedSessionErrors[sessionId] = errorMessage
}
const result = {
success: false,
successCount: 0,
failCount: normalizedSessionIds.length,
failedSessionIds: normalizedSessionIds,
failedSessionErrors,
error: `导出 Worker 执行失败: ${errorMessage}`
}
return await finalizeExportTaskControlResult(taskId, result) return await finalizeExportTaskControlResult(taskId, result)
} finally { } finally {
if (taskId) activeExportTasks.delete(taskId) if (taskId) activeExportTasks.delete(taskId)
@@ -3203,12 +3228,136 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
return exportService.exportSessionToChatLab(sessionId, outputPath, options) const cfg = configService || new ConfigService()
configService = cfg
const imageKeys = cfg.getImageKeysForCurrentWxid()
const workerPath = join(__dirname, 'exportWorker.js')
try {
return await new Promise<any>((resolve) => {
const worker = new Worker(workerPath, {
workerData: {
mode: 'single',
sessionId,
outputPath,
options,
dbPath: String(cfg.get('dbPath') || '').trim(),
decryptKey: String(cfg.get('decryptKey') || '').trim(),
myWxid: String(cfg.get('myWxid') || '').trim(),
imageXorKey: imageKeys.xorKey,
imageAesKey: imageKeys.aesKey,
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
userDataPath: app.getPath('userData'),
logEnabled: cfg.get('logEnabled')
}
})
let settled = false
const finalize = (value: any) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
resolve(value)
}
const fail = (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-single] ${errorMessage}`)
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'export:progress') {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', msg.data)
}
return
}
if (msg && msg.type === 'export:result') {
finalize(msg.data)
return
}
if (msg && msg.type === 'export:error') {
fail(String(msg.error || '导出 Worker 执行失败'))
}
})
worker.on('error', fail)
worker.on('exit', (code) => {
if (settled) return
if (code === 0) {
finalize({ success: false, error: '导出 Worker 未返回结果' })
} else {
fail(`导出 Worker 异常退出: ${code}`)
}
})
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-single] ${errorMessage}`)
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
}
}) })
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => { ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
return contactExportService.exportContacts(outputDir, options) const cfg = configService || new ConfigService()
configService = cfg
const workerPath = join(__dirname, 'exportWorker.js')
try {
return await new Promise<any>((resolve) => {
const worker = new Worker(workerPath, {
workerData: {
mode: 'contacts',
outputDir,
options,
dbPath: String(cfg.get('dbPath') || '').trim(),
decryptKey: String(cfg.get('decryptKey') || '').trim(),
myWxid: String(cfg.get('myWxid') || '').trim(),
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
userDataPath: app.getPath('userData'),
logEnabled: cfg.get('logEnabled')
}
})
let settled = false
const finalize = (value: any) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
resolve(value)
}
const fail = (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-contacts] ${errorMessage}`)
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'export:result') {
finalize(msg.data)
return
}
if (msg && msg.type === 'export:error') {
fail(String(msg.error || '导出 Worker 执行失败'))
}
})
worker.on('error', fail)
worker.on('exit', (code) => {
if (settled) return
if (code === 0) {
finalize({ success: false, error: '导出 Worker 未返回结果' })
} else {
fail(`导出 Worker 异常退出: ${code}`)
}
})
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-contacts] ${errorMessage}`)
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
}
}) })
// 数据分析相关 // 数据分析相关

View File

@@ -112,6 +112,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
exportWriteLayout?: 'A' | 'B' | 'C'
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
@@ -271,7 +272,7 @@ async function parallelLimit<T, R>(
class ExportService { class ExportService {
private configService: ConfigService private configService: ConfigService
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }> private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
private inlineEmojiCache: LRUCache<string, string> private inlineEmojiCache: LRUCache<string, string>
private htmlStyleCache: string | null = null private htmlStyleCache: string | null = null
@@ -287,6 +288,8 @@ class ExportService {
private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaExportTelemetry: MediaExportTelemetry | null = null
private mediaRunSourceDedupMap = new Map<string, string>() private mediaRunSourceDedupMap = new Map<string, string>()
private mediaRunMissingImageKeys = new Set<string>() private mediaRunMissingImageKeys = new Set<string>()
private activeChatImagePipelineCount = 0
private chatImagePipelineWaiters: Array<() => void> = []
private mediaFileCacheCleanupPending: Promise<void> | null = null private mediaFileCacheCleanupPending: Promise<void> | null = null
private mediaFileCacheLastCleanupAt = 0 private mediaFileCacheLastCleanupAt = 0
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
@@ -320,8 +323,22 @@ class ExportService {
return error return error
} }
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void { setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
this.runtimeConfig = config this.runtimeConfig = config
imageDecryptService.setRuntimeConfig({
dbPath: config?.dbPath,
myWxid: config?.myWxid,
imageXorKey: config?.imageXorKey,
imageAesKey: config?.imageAesKey
})
}
private getConfiguredDbPath(): string {
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
}
private getConfiguredMyWxid(): string {
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
} }
private normalizeSessionIds(sessionIds: string[]): string[] { private normalizeSessionIds(sessionIds: string[]): string[] {
@@ -354,6 +371,33 @@ class ExportService {
return { start, end } return { start, end }
} }
private normalizeMaxFileSizeMb(value: unknown): number | undefined {
const raw = Number(value)
if (!Number.isFinite(raw) || raw <= 0) return undefined
return Math.floor(raw)
}
private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions {
const normalizedDateRange = this.normalizeExportDateRange(options.dateRange)
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb)
const normalizedWriteLayout = this.resolveExportWriteLayout(options)
return {
...options,
dateRange: normalizedDateRange,
maxFileSizeMb: normalizedMaxFileSizeMb,
exportWriteLayout: normalizedWriteLayout
}
}
private resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' {
const optionLayout = options?.exportWriteLayout
if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout
const rawWriteLayout = this.configService.get('exportWriteLayout')
return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
: 'B'
}
private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string { private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
const normalized = this.normalizeExportDateRange(dateRange) const normalized = this.normalizeExportDateRange(dateRange)
if (!normalized) return 'all' if (!normalized) return 'all'
@@ -370,8 +414,8 @@ class ExportService {
const normalizedIds = this.normalizeSessionIds(sessionIds).sort() const normalizedIds = this.normalizeSessionIds(sessionIds).sort()
const senderToken = String(options.senderUsername || '').trim() const senderToken = String(options.senderUsername || '').trim()
const dateToken = this.getExportStatsDateRangeToken(options.dateRange) const dateToken = this.getExportStatsDateRangeToken(options.dateRange)
const dbPath = String(this.configService.get('dbPath') || '').trim() const dbPath = this.getConfiguredDbPath()
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim() const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim()
return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}` return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
} }
@@ -712,6 +756,20 @@ class ExportService {
this.mediaRunMissingImageKeys.clear() this.mediaRunMissingImageKeys.clear()
} }
private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> {
while (this.activeChatImagePipelineCount >= 2) {
await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve))
}
this.activeChatImagePipelineCount += 1
try {
return await fn()
} finally {
this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1)
const next = this.chatImagePipelineWaiters.shift()
if (next) next()
}
}
private getMediaTelemetrySnapshot(): Partial<ExportProgress> { private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
const stats = this.mediaExportTelemetry const stats = this.mediaExportTelemetry
if (!stats) return {} if (!stats) return {}
@@ -1577,8 +1635,8 @@ class ExportService {
} }
private resolveStrictEmoticonDbPath(): string | null { private resolveStrictEmoticonDbPath(): string | null {
const dbPath = String(this.configService.get('dbPath') || '').trim() const dbPath = this.getConfiguredDbPath()
const rawWxid = String(this.configService.get('myWxid') || '').trim() const rawWxid = this.getConfiguredMyWxid()
const cleanedWxid = this.cleanAccountDirName(rawWxid) const cleanedWxid = this.cleanAccountDirName(rawWxid)
const token = `${dbPath}::${rawWxid}::${cleanedWxid}` const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
if (token === this.emoticonDbPathCacheToken) { if (token === this.emoticonDbPathCacheToken) {
@@ -1823,8 +1881,8 @@ class ExportService {
} }
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() const wxid = this.getConfiguredMyWxid()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() const dbPath = this.getConfiguredDbPath()
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim() const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' } if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' } if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
@@ -4092,44 +4150,79 @@ class ExportService {
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => { const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
if (!imageMd5 && !imageDatName) return null if (!imageMd5 && !imageDatName) return null
return this.runWithChatImagePipelineLimit(async () => {
const pickResolvedImagePath = (result: any): string | null => {
if (!result?.success) return null
const resolved = String(result.localPath || '').trim()
return resolved || null
}
const decryptResult = await imageDecryptService.decryptImage({ const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => {
sessionId, const cachedResult = await imageDecryptService.resolveCachedImage({
imageMd5, sessionId,
imageDatName, imageMd5: candidateMd5,
createTime: msg.createTime, imageDatName: candidateDatName,
force: true, // 导出优先高清,失败再回退缩略图 createTime: msg.createTime,
preferFilePath: true, preferFilePath: true,
hardlinkOnly: true, hardlinkOnly: true,
disableUpdateCheck: true, disableUpdateCheck: true,
allowCacheIndex: !imageMd5, allowCacheIndex: true,
suppressEvents: true suppressEvents: true
})
return pickResolvedImagePath(cachedResult)
}
const cachedPath = await resolveCachedPath(imageMd5, imageDatName)
if (cachedPath) {
return cachedPath
}
const decryptResult = await imageDecryptService.decryptImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
force: false,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true
})
const decryptedPath = pickResolvedImagePath(decryptResult)
if (decryptedPath) return decryptedPath
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
const fallback = await chatService.getImageData(sessionId, String(localId))
if (fallback.success && fallback.data) {
const buffer = Buffer.from(fallback.data, 'base64')
const mime = this.detectMimeType(buffer) || 'image/jpeg'
return `data:${mime};base64,${fallback.data}`
}
}
if (decryptResult.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
}
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: true,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
return thumbResult.localPath
}
return null
}) })
if (decryptResult.success && decryptResult.localPath) {
return decryptResult.localPath
}
if (decryptResult.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
}
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
preferFilePath: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
return thumbResult.localPath
}
return null
} }
// 使用消息对象中已提取的字段,先尝试快速导出。 // 使用消息对象中已提取的字段,先尝试快速导出。
@@ -4235,11 +4328,10 @@ class ExportService {
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase() const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
if (imageMd5) { if (imageMd5) {
imageMd5Set.add(imageMd5) imageMd5Set.add(imageMd5)
} else { }
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
if (md5Pattern.test(imageDatName)) { if (md5Pattern.test(imageDatName)) {
imageMd5Set.add(imageDatName) imageMd5Set.add(imageDatName)
}
} }
} }
@@ -4487,16 +4579,89 @@ class ExportService {
*/ */
private extractImageDatName(content: string): string | undefined { private extractImageDatName(content: string): string | undefined {
if (!content) return undefined if (!content) return undefined
// 尝试从 cdnthumburl 或其他字段提取 const candidate =
const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content) this.extractXmlValue(content, 'imgname') ||
if (urlMatch) { this.extractXmlValue(content, 'cdnmidimgurl') ||
const urlParts = urlMatch[1].split('/') this.extractXmlValue(content, 'cdnthumburl') ||
const last = urlParts[urlParts.length - 1] this.extractXmlAttribute(content, 'img', 'imgname') ||
if (last && last.includes('_')) { this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') ||
return last.split('_')[0] this.extractXmlAttribute(content, 'img', 'cdnthumburl')
} return this.normalizeImageDatNameToken(candidate)
}
private normalizeImageDatNameToken(value: unknown): string | undefined {
let text = String(value ?? '').trim()
if (!text) return undefined
text = text.replace(/&amp;/g, '&')
try {
if (text.includes('%')) text = decodeURIComponent(text)
} catch { }
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
if (datLike?.[1]) return datLike[1].toLowerCase()
const base = text
.split(/[?#]/, 1)[0]
.replace(/^.*[\\/]/, '')
.replace(/\.(?:t\.)?dat$/i, '')
.trim()
if (!base) return undefined
const cdnToken = base.includes('_') ? base.split('_')[0] : base
const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken)
if (exact?.[1]) return exact[1].toLowerCase()
const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken)
return fallback?.[1]?.toLowerCase()
}
private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined {
const buffer = this.decodePackedInfoBuffer(raw)
if (!buffer || buffer.length === 0) return undefined
const printable: number[] = []
for (const byte of buffer) {
printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20)
} }
return undefined const text = Buffer.from(printable).toString('utf-8')
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
if (datLike?.[1]) return datLike[1].toLowerCase()
const fallback = /([0-9a-fA-F]{16,})/.exec(text)
return fallback?.[1]?.toLowerCase()
}
private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined {
const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [
'image_path',
'imagePath',
'image_dat_name',
'imageDatName',
'img_path',
'imgPath',
'img_name',
'imgName'
]))
if (byColumn) return byColumn
const packedRaw = this.getRowField(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw)
if (byPacked) return byPacked
return this.extractImageDatName(content || '')
} }
/** /**
@@ -4699,8 +4864,8 @@ class ExportService {
} }
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] { private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
const dbPath = String(this.configService.get('dbPath') || '').trim() const dbPath = this.getConfiguredDbPath()
const rawWxid = String(this.configService.get('myWxid') || '').trim() const rawWxid = this.getConfiguredMyWxid()
const cleanedWxid = this.cleanAccountDirName(rawWxid) const cleanedWxid = this.cleanAccountDirName(rawWxid)
if (!dbPath) return [] if (!dbPath) return []
@@ -5050,10 +5215,7 @@ class ExportService {
const exportMediaEnabled = options.exportMedia === true && const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
const outputDir = path.dirname(outputPath) const outputDir = path.dirname(outputPath)
const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = this.resolveExportWriteLayout(options)
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. // A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories.
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') { if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
return { return {
@@ -5229,7 +5391,7 @@ class ExportService {
: await wcdbService.openMessageCursor( : await wcdbService.openMessageCursor(
sessionId, sessionId,
batchSize, batchSize,
true, false,
beginTime, beginTime,
endTime endTime
) )
@@ -5417,7 +5579,7 @@ class ExportService {
if (collectMode === 'full' || collectMode === 'media-fast') { if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。 // 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined
videoMd5 = this.extractVideoFileNameFromRow(row, content) videoMd5 = this.extractVideoFileNameFromRow(row, content)
xmlType = rowFileHints.xmlType xmlType = rowFileHints.xmlType
fileName = rowFileHints.fileName fileName = rowFileHints.fileName
@@ -5439,7 +5601,7 @@ class ExportService {
if (localType === 3 && content) { if (localType === 3 && content) {
// 图片消息 // 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content) imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content) imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content)
} else if (localType === 43 && content) { } else if (localType === 43 && content) {
// 视频消息 // 视频消息
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content) videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
@@ -5587,9 +5749,51 @@ class ExportService {
} }
} }
if (rows.length > 1) {
rows.sort((a, b) => {
const timeDelta = (a.createTime || 0) - (b.createTime || 0)
if (timeDelta !== 0) return timeDelta
return (a.localId || 0) - (b.localId || 0)
})
}
return { rows, memberSet, firstTime, lastTime } return { rows, memberSet, firstTime, lastTime }
} }
private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> {
try {
const logResult = await wcdbService.getLogs()
if (!logResult.success || !Array.isArray(logResult.logs)) return undefined
const sid = String(sessionId || '').trim()
const interesting = logResult.logs
.filter((line) => {
const text = String(line || '')
if (sid && text.includes(sid)) return true
return text.includes('QueryMessageBatch') ||
text.includes('InitExportCursorHeap') ||
text.includes('cursor_init') ||
text.includes('fetch_message_batch') ||
text.includes('open_message_cursor')
})
.slice(-8)
if (interesting.length === 0) return undefined
return interesting.join(' | ')
} catch {
return undefined
}
}
private async buildNoMessagesError(
sessionId: string,
collected: { error?: string },
fallback = '该会话在指定时间范围内没有消息'
): Promise<string> {
if (collected.error) return collected.error
const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId)
if (!nativeLogSummary) return fallback
return `${fallback}WCDB日志${nativeLogSummary}`
}
private async backfillMediaFieldsFromMessageDetail( private async backfillMediaFieldsFromMessageDetail(
sessionId: string, sessionId: string,
rows: any[], rows: any[],
@@ -5608,7 +5812,7 @@ class ExportService {
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
} }
if (!targetMediaTypes.has(msg.localType)) return false if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5 if (msg.localType === 43) return !msg.videoMd5
return false return false
@@ -5639,7 +5843,7 @@ class ExportService {
if (msg.localType === 3) { if (msg.localType === 3) {
const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase() const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
const imageDatName = (String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) || '').toLowerCase() const imageDatName = this.extractImageDatNameFromRow(row, content) || ''
if (imageMd5) msg.imageMd5 = imageMd5 if (imageMd5) msg.imageMd5 = imageMd5
if (imageDatName) msg.imageDatName = imageDatName if (imageDatName) msg.imageDatName = imageDatName
return return
@@ -6111,7 +6315,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -6149,7 +6353,7 @@ class ExportService {
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (totalMessages === 0) { if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control) await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
@@ -6649,7 +6853,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -6687,7 +6891,7 @@ class ExportService {
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (totalMessages === 0) { if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -7380,7 +7584,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -7423,7 +7627,7 @@ class ExportService {
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (totalMessages === 0) { if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -8264,7 +8468,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -8301,7 +8505,7 @@ class ExportService {
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (totalMessages === 0) { if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -8662,7 +8866,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -8697,7 +8901,7 @@ class ExportService {
) )
let totalMessages = collected.rows.length let totalMessages = collected.rows.length
if (totalMessages === 0) { if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -9113,7 +9317,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
@@ -9152,7 +9356,7 @@ class ExportService {
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (collected.rows.length === 0) { if (collected.rows.length === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' } return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
} }
const totalMessages = collected.rows.length const totalMessages = collected.rows.length
@@ -9948,6 +10152,7 @@ class ExportService {
pendingSessionIds?: string[] pendingSessionIds?: string[]
successSessionIds?: string[] successSessionIds?: string[]
failedSessionIds?: string[] failedSessionIds?: string[]
failedSessionErrors?: Record<string, string>
sessionOutputPaths?: Record<string, string> sessionOutputPaths?: Record<string, string>
error?: string error?: string
}> { }> {
@@ -9955,6 +10160,7 @@ class ExportService {
let failCount = 0 let failCount = 0
const successSessionIds: string[] = [] const successSessionIds: string[] = []
const failedSessionIds: string[] = [] const failedSessionIds: string[] = []
const failedSessionErrors: Record<string, string> = {}
const sessionOutputPaths: Record<string, string> = {} const sessionOutputPaths: Record<string, string> = {}
const progressEmitter = this.createProgressEmitter(onProgress) const progressEmitter = this.createProgressEmitter(onProgress)
let attachMediaTelemetry = false let attachMediaTelemetry = false
@@ -9972,9 +10178,10 @@ class ExportService {
} }
this.resetMediaRuntimeState() this.resetMediaRuntimeState()
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) const normalizedOptions = this.normalizeExportOptionsForRun(options)
? { ...options, exportVoiceAsText: false } const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions)
: options ? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
const exportMediaEnabled = effectiveOptions.exportMedia === true && const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles) Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
@@ -9982,10 +10189,7 @@ class ExportService {
if (exportMediaEnabled) { if (exportMediaEnabled) {
this.triggerMediaFileCacheCleanup() this.triggerMediaFileCacheCleanup()
} }
const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = this.resolveExportWriteLayout(effectiveOptions)
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
: 'A'
const exportBaseDir = writeLayout === 'A' const exportBaseDir = writeLayout === 'A'
? path.join(outputDir, 'texts') ? path.join(outputDir, 'texts')
: outputDir : outputDir
@@ -10020,7 +10224,6 @@ class ExportService {
const queue = [...sessionIds] const queue = [...sessionIds]
let pauseRequested = false let pauseRequested = false
let stopRequested = false let stopRequested = false
const emptySessionIds = new Set<string>()
const sessionMessageCountHints = new Map<string, number>() const sessionMessageCountHints = new Map<string, number>()
const sessionLatestTimestampHints = new Map<string, number>() const sessionLatestTimestampHints = new Map<string, number>()
const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid) const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
@@ -10033,17 +10236,12 @@ class ExportService {
if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) { if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp))) sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
} }
if (snapshot.totalCount <= 0) {
emptySessionIds.add(sessionId)
}
} }
} }
const canUseSessionSnapshotHints = isTextContentBatchExport && const canUseSessionSnapshotHints = isTextContentBatchExport &&
this.isUnboundedDateRange(effectiveOptions.dateRange) && this.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim() !String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = !isTextContentBatchExport && const canFastSkipEmptySessions = false
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
const precheckSessionIds = canFastSkipEmptySessions const precheckSessionIds = canFastSkipEmptySessions
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId)) ? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
@@ -10082,9 +10280,6 @@ class ExportService {
if (typeof count === 'number' && Number.isFinite(count) && count >= 0) { if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count))) sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
} }
if (typeof count === 'number' && Number.isFinite(count) && count <= 0) {
emptySessionIds.add(batchSessionId)
}
} }
} }
@@ -10154,6 +10349,7 @@ class ExportService {
pendingSessionIds: [...queue], pendingSessionIds: [...queue],
successSessionIds, successSessionIds,
failedSessionIds, failedSessionIds,
failedSessionErrors,
sessionOutputPaths sessionOutputPaths
} }
} }
@@ -10166,6 +10362,7 @@ class ExportService {
pendingSessionIds: [...queue], pendingSessionIds: [...queue],
successSessionIds, successSessionIds,
failedSessionIds, failedSessionIds,
failedSessionErrors,
sessionOutputPaths sessionOutputPaths
} }
} }
@@ -10177,46 +10374,6 @@ class ExportService {
const messageCountHint = sessionMessageCountHints.get(sessionId) const messageCountHint = sessionMessageCountHints.get(sessionId)
const latestTimestampHint = sessionLatestTimestampHints.get(sessionId) const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)
if (
isTextContentBatchExport &&
typeof messageCountHint === 'number' &&
messageCountHint <= 0
) {
successCount++
successSessionIds.push(sessionId)
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '该会话没有消息,已跳过',
estimatedTotalMessages: 0,
exportedMessages: 0
}, { force: true })
return 'done'
}
if (emptySessionIds.has(sessionId)) {
successCount++
successSessionIds.push(sessionId)
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '该会话没有消息,已跳过',
estimatedTotalMessages: 0,
exportedMessages: 0
}, { force: true })
return 'done'
}
const sessionProgress = (progress: ExportProgress) => { const sessionProgress = (progress: ExportProgress) => {
const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100 const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0 const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
@@ -10339,6 +10496,7 @@ class ExportService {
} else { } else {
failCount++ failCount++
failedSessionIds.push(sessionId) failedSessionIds.push(sessionId)
failedSessionErrors[sessionId] = result.error || '导出失败'
console.error(`导出 ${sessionId} 失败:`, result.error) console.error(`导出 ${sessionId} 失败:`, result.error)
} }
@@ -10433,6 +10591,7 @@ class ExportService {
pendingSessionIds, pendingSessionIds,
successSessionIds, successSessionIds,
failedSessionIds, failedSessionIds,
failedSessionErrors,
sessionOutputPaths sessionOutputPaths
} }
} }
@@ -10445,6 +10604,7 @@ class ExportService {
pendingSessionIds, pendingSessionIds,
successSessionIds, successSessionIds,
failedSessionIds, failedSessionIds,
failedSessionErrors,
sessionOutputPaths sessionOutputPaths
} }
} }
@@ -10458,7 +10618,20 @@ class ExportService {
}, { force: true }) }, { force: true })
progressEmitter.flush() progressEmitter.flush()
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths } const allFailed = successCount === 0 && failCount > 0
const failureSummary = allFailed
? Object.values(failedSessionErrors).slice(0, 3).join('') || '所有会话导出失败'
: undefined
return {
success: !allFailed,
successCount,
failCount,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths,
error: failureSummary
}
} catch (e) { } catch (e) {
progressEmitter.flush() progressEmitter.flush()
return { success: false, successCount, failCount, error: String(e) } return { success: false, successCount, failCount, error: String(e) }

View File

@@ -81,6 +81,7 @@ export class ImageDecryptService {
private pending = new Map<string, Promise<DecryptResult>>() private pending = new Map<string, Promise<DecryptResult>>()
private updateFlags = new Map<string, boolean>() private updateFlags = new Map<string, boolean>()
private nativeLogged = false private nativeLogged = false
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
private datNameScanMissAt = new Map<string, number>() private datNameScanMissAt = new Map<string, number>()
private readonly datNameScanMissTtlMs = 1200 private readonly datNameScanMissTtlMs = 1200
private readonly accountDirCache = new Map<string, string>() private readonly accountDirCache = new Map<string, string>()
@@ -99,6 +100,32 @@ export class ImageDecryptService {
return this.shouldEmitImageEvents(payload) return this.shouldEmitImageEvents(payload)
} }
setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
this.runtimeConfig = config
}
private getConfiguredDbPath(): string {
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
}
private getConfiguredMyWxid(): string {
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
}
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
if (hasRuntimeXorKey || runtimeAesKey) {
const fallback = this.configService.getImageKeysForCurrentWxid()
return {
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
aesKey: runtimeAesKey || fallback.aesKey
}
}
return this.configService.getImageKeysForCurrentWxid()
}
private logInfo(message: string, meta?: Record<string, unknown>): void { private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
@@ -266,8 +293,8 @@ export class ImageDecryptService {
) )
if (normalizedList.length === 0) return if (normalizedList.length === 0) return
const wxid = this.configService.get('myWxid') const wxid = this.getConfiguredMyWxid()
const dbPath = this.configService.get('dbPath') const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) return if (!wxid || !dbPath) return
const accountDir = this.resolveAccountDir(dbPath, wxid) const accountDir = this.resolveAccountDir(dbPath, wxid)
@@ -294,8 +321,8 @@ export class ImageDecryptService {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running') this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
try { try {
const wxid = this.configService.get('myWxid') const wxid = this.getConfiguredMyWxid()
const dbPath = this.configService.get('dbPath') const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) { if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失') this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
@@ -404,7 +431,7 @@ export class ImageDecryptService {
} }
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置 // 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
const imageKeys = this.configService.getImageKeysForCurrentWxid() const imageKeys = this.getConfiguredImageKeys()
const xorKeyRaw = imageKeys.xorKey const xorKeyRaw = imageKeys.xorKey
// 支持十六进制格式(如 0x53和十进制格式 // 支持十六进制格式(如 0x53和十进制格式
let xorKey: number let xorKey: number
@@ -427,7 +454,7 @@ export class ImageDecryptService {
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : '' const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
const aesKeyForNative = aesKeyText || undefined const aesKeyForNative = aesKeyText || undefined
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) }) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running') this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative) const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
if (!nativeResult) { if (!nativeResult) {
@@ -527,8 +554,8 @@ export class ImageDecryptService {
} }
private resolveCurrentAccountDir(): string | null { private resolveCurrentAccountDir(): string | null {
const wxid = this.configService.get('myWxid') const wxid = this.getConfiguredMyWxid()
const dbPath = this.configService.get('dbPath') const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) return null if (!wxid || !dbPath) return null
return this.resolveAccountDir(dbPath, wxid) return this.resolveAccountDir(dbPath, wxid)
} }
@@ -1551,7 +1578,117 @@ export class ImageDecryptService {
}) })
} }
} }
return result if (result) return result
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
if (fallback) {
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
}
return fallback
}
private tryDecryptDatWithJs(
datPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
try {
const encrypted = readFileSync(datPath)
const directExt = this.detectImageExtension(encrypted)
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
const candidates: Buffer[] = []
const aesKeyText = String(aesKey || '').trim()
const datVersion = this.getDatVersion(encrypted)
if (datVersion === 2 && aesKeyText.length >= 16) {
try {
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
} catch { }
}
if (datVersion !== 2) {
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
}
for (const candidate of candidates) {
const ext = this.detectImageExtension(candidate)
if (ext) return { data: candidate, ext, isWxgf: false }
}
} catch (error) {
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
}
return null
}
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
const output = Buffer.allocUnsafe(data.length)
for (let i = 0; i < data.length; i += 1) {
output[i] = data[i] ^ xorKey
}
return output
}
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
if (data.length < 0x0f) {
throw new Error('dat file too small')
}
const header = data.subarray(0, 0x0f)
const payload = data.subarray(0x0f)
const aesSize = this.readInt32LeSafe(header, 6)
const xorSize = this.readInt32LeSafe(header, 10)
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
const aesData = payload.subarray(0, alignedAesSize)
let plainAes = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
decipher.setAutoPadding(false)
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
let rawData = Buffer.alloc(0)
let decodedXor = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) throw new Error('invalid raw size')
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
decodedXor = Buffer.allocUnsafe(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
decodedXor[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
}
return Buffer.concat([plainAes, rawData, decodedXor])
}
private getDatVersion(data: Buffer): number {
if (data.length < 6) return 0
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
if (data.subarray(0, 6).equals(sigV1)) return 1
if (data.subarray(0, 6).equals(sigV2)) return 2
return 0
}
private readInt32LeSafe(buffer: Buffer, offset: number): number {
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
}
private strictRemovePkcs7Padding(data: Buffer): Buffer {
if (data.length === 0) throw new Error('empty decrypted data')
const pad = data[data.length - 1]
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
for (let i = data.length - pad; i < data.length; i += 1) {
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
}
return data.subarray(0, data.length - pad)
} }
private detectImageExtension(buffer: Buffer): string | null { private detectImageExtension(buffer: Buffer): string | null {

View File

@@ -5192,6 +5192,7 @@ function ExportPage() {
exportConcurrency: sourceOptions.exportConcurrency, exportConcurrency: sourceOptions.exportConcurrency,
fileNamingMode: exportDefaultFileNamingMode, fileNamingMode: exportDefaultFileNamingMode,
sessionLayout, sessionLayout,
exportWriteLayout: writeLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
dateRange: sourceOptions.useAllTime dateRange: sourceOptions.useAllTime
? null ? null
@@ -6008,9 +6009,10 @@ function ExportPage() {
} }
return { return {
...task.template.optionTemplate, ...task.template.optionTemplate,
exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout,
dateRange dateRange
} }
}, []) }, [writeLayout])
const enqueueAutomationTask = useCallback(( const enqueueAutomationTask = useCallback((
task: ExportAutomationTask, task: ExportAutomationTask,

View File

@@ -1101,6 +1101,7 @@ export interface ElectronAPI {
pendingSessionIds?: string[] pendingSessionIds?: string[]
successSessionIds?: string[] successSessionIds?: string[]
failedSessionIds?: string[] failedSessionIds?: string[]
failedSessionErrors?: Record<string, string>
sessionOutputPaths?: Record<string, string> sessionOutputPaths?: Record<string, string>
error?: string error?: string
}> }>
@@ -1269,6 +1270,7 @@ export interface ExportOptions {
txtColumns?: string[] txtColumns?: string[]
fileNamingMode?: 'classic' | 'date-range' fileNamingMode?: 'classic' | 'date-range'
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
exportWriteLayout?: 'A' | 'B' | 'C'
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number