From ec0eb64ffdaec039da40525d84c61cc5fe21a168 Mon Sep 17 00:00:00 2001 From: ace Date: Fri, 27 Feb 2026 11:07:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=A7=A3=E5=AF=86=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 4 +- electron/preload.ts | 2 +- electron/services/chatService.ts | 82 +++- electron/services/dbPathService.ts | 32 +- electron/services/imageDecryptService.ts | 74 +++- electron/services/keyService.ts | 49 ++- electron/services/videoService.ts | 523 ++++++++++++++--------- package-lock.json | 19 +- src/pages/ChatPage.tsx | 4 +- 9 files changed, 522 insertions(+), 267 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index ab9128d..f9d3bcd 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -852,9 +852,9 @@ function registerIpcHandlers() { }) // 视频相关 - ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, sessionId?: string) => { try { - const result = await videoService.getVideoInfo(videoMd5) + const result = await videoService.getVideoInfo(videoMd5, sessionId) return { success: true, ...result } } catch (e) { return { success: false, error: String(e), exists: false } diff --git a/electron/preload.ts b/electron/preload.ts index 4cf585b..d03b94e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -196,7 +196,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // 视频 video: { - getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + getVideoInfo: (videoMd5: string, sessionId?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, sessionId), parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index be86d54..845a1bd 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4478,27 +4478,77 @@ class ChatService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const normalized = dbPath.replace(/[\\\\/]+$/, '') + const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() + const normalized = dbPath.replace(/[\\/]+$/, '') - // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) - // 则向上回溯到账号目录 - if (basename(normalized).toLowerCase() === 'db_storage') { - return dirname(normalized) - } - const dir = dirname(normalized) - if (basename(dir).toLowerCase() === 'db_storage') { - return dirname(dir) + const candidates: { path: string; mtime: number }[] = [] + + // 检查直接路径 + const direct = join(normalized, cleanedWxid) + if (existsSync(direct) && this.isAccountDir(direct)) { + candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) } - // 否则,dbPath 应该是数据库根目录(如 xwechat_files) - // 账号目录应该是 {dbPath}/{wxid} - const accountDirWithWxid = join(normalized, wxid) - if (existsSync(accountDirWithWxid)) { - return accountDirWithWxid + // 检查 dbPath 本身是否就是账号目录 + if (this.isAccountDir(normalized)) { + candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) } - // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) - return normalized + // 扫描 dbPath 下的所有子目录寻找匹配的 wxid + try { + if (existsSync(normalized) && statSync(normalized).isDirectory()) { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) + try { + if (!statSync(entryPath).isDirectory()) continue + } catch { continue } + + const lowerEntry = entry.toLowerCase() + if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { + if (this.isAccountDir(entryPath)) { + if (!candidates.some(c => c.path === entryPath)) { + candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) + } + } + } + } + } + } catch { } + + if (candidates.length === 0) return null + + // 按修改时间降序排序,取最新的 + candidates.sort((a, b) => b.mtime - a.mtime) + return candidates[0].path + } + + private isAccountDir(dirPath: string): boolean { + return ( + existsSync(join(dirPath, 'db_storage')) || + existsSync(join(dirPath, 'FileStorage', 'Image')) || + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) + ) + } + + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + return mtime + } catch { + return 0 + } } private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ee15b02..122c33a 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -77,7 +77,8 @@ export class DbPathService { return ( existsSync(join(entryPath, 'db_storage')) || existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) + existsSync(join(entryPath, 'FileStorage', 'Image2')) || + existsSync(join(entryPath, 'msg', 'attach')) ) } @@ -94,22 +95,21 @@ export class DbPathService { const accountStat = statSync(entryPath) let latest = accountStat.mtimeMs - const dbPath = join(entryPath, 'db_storage') - if (existsSync(dbPath)) { - const dbStat = statSync(dbPath) - latest = Math.max(latest, dbStat.mtimeMs) - } + const checkSubDirs = [ + 'db_storage', + join('FileStorage', 'Image'), + join('FileStorage', 'Image2'), + join('msg', 'attach') + ] - const imagePath = join(entryPath, 'FileStorage', 'Image') - if (existsSync(imagePath)) { - const imageStat = statSync(imagePath) - latest = Math.max(latest, imageStat.mtimeMs) - } - - const image2Path = join(entryPath, 'FileStorage', 'Image2') - if (existsSync(image2Path)) { - const image2Stat = statSync(image2Path) - latest = Math.max(latest, image2Stat.mtimeMs) + for (const sub of checkSubDirs) { + const fullPath = join(entryPath, sub) + if (existsSync(fullPath)) { + try { + const s = statSync(fullPath) + latest = Math.max(latest, s.mtimeMs) + } catch { } + } } return latest diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 7a8c043..20f034b 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -329,28 +329,78 @@ export class ImageDecryptService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid) + const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() const normalized = dbPath.replace(/[\\/]+$/, '') + const candidates: { path: string; mtime: number }[] = [] + + // 检查直接路径 const direct = join(normalized, cleanedWxid) - if (existsSync(direct)) return direct + if (existsSync(direct) && this.isAccountDir(direct)) { + candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) + } - if (this.isAccountDir(normalized)) return normalized + // 检查 dbPath 本身是否就是账号目录 + if (this.isAccountDir(normalized)) { + candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) + } + // 扫描 dbPath 下的所有子目录寻找匹配的 wxid try { - const entries = readdirSync(normalized) - const lowerWxid = cleanedWxid.toLowerCase() - for (const entry of entries) { - const entryPath = join(normalized, entry) - if (!this.isDirectory(entryPath)) continue - const lowerEntry = entry.toLowerCase() - if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { - if (this.isAccountDir(entryPath)) return entryPath + if (existsSync(normalized) && this.isDirectory(normalized)) { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) + if (!this.isDirectory(entryPath)) continue + + const lowerEntry = entry.toLowerCase() + // 匹配原 wxid 或带有后缀的 wxid (如 wxid_xxx_1234) + if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { + if (this.isAccountDir(entryPath)) { + if (!candidates.some(c => c.path === entryPath)) { + candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) + } + } + } } } } catch { } - return null + if (candidates.length === 0) return null + + // 按修改时间降序排序,取最新的(最可能是当前活跃的) + candidates.sort((a, b) => b.mtime - a.mtime) + + if (candidates.length > 1) { + this.logInfo('找到多个候选账号目录,选择最新修改的一个', { + selected: candidates[0].path, + all: candidates.map(c => c.path) + }) + } + + return candidates[0].path + } + + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + + // 检查几个关键子目录的修改时间,以更准确地反映活动状态 + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + + return mtime + } catch { + return 0 + } } /** diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 34e7bd0..fb21b2f 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -744,7 +744,8 @@ export class KeyService { return ( existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) ) } @@ -761,8 +762,8 @@ export class KeyService { private listAccountDirs(rootDir: string): string[] { try { const entries = readdirSync(rootDir) - const high: string[] = [] - const low: string[] = [] + const candidates: { path: string; mtime: number; isAccount: boolean }[] = [] + for (const entry of entries) { const fullPath = join(rootDir, entry) try { @@ -775,18 +776,48 @@ export class KeyService { continue } - if (this.isAccountDir(fullPath)) { - high.push(fullPath) - } else { - low.push(fullPath) - } + const isAccount = this.isAccountDir(fullPath) + candidates.push({ + path: fullPath, + mtime: this.getDirMtime(fullPath), + isAccount + }) } - return high.length ? high.sort() : low.sort() + + // 优先选择有效账号目录,然后按修改时间从新到旧排序 + return candidates + .sort((a, b) => { + if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1 + return b.mtime - a.mtime + }) + .map(c => c.path) } catch { return [] } } + private getDirMtime(dirPath: string): number { + try { + const stat = statSync(dirPath) + let mtime = stat.mtimeMs + + // 检查几个关键子目录的修改时间,以更准确地反映活动状态 + const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] + for (const sub of subDirs) { + const fullPath = join(dirPath, sub) + if (existsSync(fullPath)) { + try { + mtime = Math.max(mtime, statSync(fullPath).mtimeMs) + } catch { } + } + } + + return mtime + } catch { + return 0 + } + } + private normalizeExistingDir(inputPath: string): string | null { const trimmed = inputPath.replace(/[\\\\/]+$/, '') if (!existsSync(trimmed)) return null diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 6b44c2e..089142a 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,11 +1,13 @@ -import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { join, basename, extname, dirname } from 'path' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } from 'fs' +import { app } from 'electron' import { ConfigService } from './config' import Database from 'better-sqlite3' import { wcdbService } from './wcdbService' +import crypto from 'crypto' export interface VideoInfo { - videoUrl?: string // 视频文件路径(用于 readFile) + videoUrl?: string // 视频文件路径 coverUrl?: string // 封面 data URL thumbUrl?: string // 缩略图 data URL exists: boolean @@ -13,266 +15,379 @@ export interface VideoInfo { class VideoService { private configService: ConfigService + private resolvedCache = new Map() // md5 -> localPath constructor() { this.configService = new ConfigService() } - /** - * 获取数据库根目录 - */ + private logInfo(message: string, meta?: Record): void { + if (!this.configService.get('logEnabled')) return + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logLine = `[${timestamp}] [VideoService] ${message}${metaStr}\n` + this.writeLog(logLine) + } + + private logError(message: string, error?: unknown, meta?: Record): void { + if (!this.configService.get('logEnabled')) return + const timestamp = new Date().toISOString() + const errorStr = error ? ` Error: ${String(error)}` : '' + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logLine = `[${timestamp}] [VideoService] ERROR: ${message}${errorStr}${metaStr}\n` + console.error(`[VideoService] ${message}`, error, meta) + this.writeLog(logLine) + } + + private writeLog(line: string): void { + try { + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }) + } + appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) + } catch (err) { + console.error('写入日志失败:', err) + } + } + private getDbPath(): string { return this.configService.get('dbPath') || '' } - /** - * 获取当前用户的wxid - */ private getMyWxid(): string { return this.configService.get('myWxid') || '' } - /** - * 获取缓存目录(解密后的数据库存放位置) - */ - private getCachePath(): string { + private getCacheBasePath(): string { return this.configService.getCacheBasePath() } - /** - * 清理 wxid 目录名(去掉后缀) - */ private cleanWxid(wxid: string): string { const trimmed = wxid.trim() if (!trimmed) return trimmed - if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) if (match) return match[1] - return trimmed } - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) if (suffixMatch) return suffixMatch[1] - return trimmed } - /** - * 从 video_hardlink_info_v4 表查询视频文件名 - * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) - * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db - */ - private async queryVideoFileName(md5: string): Promise { - const cachePath = this.getCachePath() - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) + private resolveAccountDir(dbPath: string, wxid: string): string | null { + if (!dbPath || !wxid) return null + const cleanedWxid = this.cleanWxid(wxid).toLowerCase() + const normalized = dbPath.replace(/[\\/]+$/, '') + const candidates: { path: string; mtime: number }[] = [] - if (!wxid) return undefined - - // 方法1:优先在 cachePath 下查找解密后的 hardlink.db - if (cachePath) { - const cacheDbPaths = [ - join(cachePath, cleanedWxid, 'hardlink.db'), - join(cachePath, wxid, 'hardlink.db'), - join(cachePath, 'hardlink.db'), - join(cachePath, 'databases', cleanedWxid, 'hardlink.db'), - join(cachePath, 'databases', wxid, 'hardlink.db') - ] - - for (const p of cacheDbPaths) { - if (existsSync(p)) { - try { - const db = new Database(p, { readonly: true }) - const row = db.prepare(` - SELECT file_name, md5 FROM video_hardlink_info_v4 - WHERE md5 = ? - LIMIT 1 - `).get(md5) as { file_name: string; md5: string } | undefined - db.close() - - if (row?.file_name) { - const realMd5 = row.file_name.replace(/\.[^.]+$/, '') - return realMd5 - } - } catch (e) { - // 忽略错误 - } - } + const checkDir = (p: string) => { + if (existsSync(p) && (existsSync(join(p, 'db_storage')) || existsSync(join(p, 'msg', 'video')) || existsSync(join(p, 'msg', 'attach')))) { + candidates.push({ path: p, mtime: this.getDirMtime(p) }) } } - // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db - if (dbPath) { - // 检查 dbPath 是否已经包含 wxid - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + checkDir(join(normalized, wxid)) + checkDir(join(normalized, cleanedWxid)) + checkDir(normalized) - const encryptedDbPaths: string[] = [] - if (dbPathContainsWxid) { - // dbPath 已包含 wxid,不需要再拼接 - encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) - } else { - // dbPath 不包含 wxid,需要拼接 - encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) - encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) - } - - for (const p of encryptedDbPaths) { - if (existsSync(p)) { + try { + if (existsSync(normalized) && statSync(normalized).isDirectory()) { + const entries = readdirSync(normalized) + for (const entry of entries) { + const entryPath = join(normalized, entry) try { - const escapedMd5 = md5.replace(/'/g, "''") - - // 用 md5 字段查询,获取 file_name - const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - - const result = await wcdbService.execQuery('media', p, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = result.rows[0] - if (row?.file_name) { - // 提取不带扩展名的文件名作为实际视频 MD5 - const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') - return realMd5 - } - } - } catch (e) { - // 忽略错误 + if (!statSync(entryPath).isDirectory()) continue + } catch { continue } + const lowerEntry = entry.toLowerCase() + if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { + checkDir(entryPath) } } } + } catch { } + + if (candidates.length === 0) return null + candidates.sort((a, b) => b.mtime - a.mtime) + return candidates[0].path + } + + private getDirMtime(dirPath: string): number { + try { + let mtime = statSync(dirPath).mtimeMs + const subs = ['db_storage', 'msg/video', 'msg/attach'] + for (const sub of subs) { + const p = join(dirPath, sub) + if (existsSync(p)) mtime = Math.max(mtime, statSync(p).mtimeMs) + } + return mtime + } catch { return 0 } + } + + private async ensureWcdbReady(): Promise { + if (wcdbService.isReady()) return true + const dbPath = this.configService.get('dbPath') + const decryptKey = this.configService.get('decryptKey') + const wxid = this.configService.get('myWxid') + if (!dbPath || !decryptKey || !wxid) return false + const cleanedWxid = this.cleanWxid(wxid) + return await wcdbService.open(dbPath, decryptKey, cleanedWxid) + } + + /** + * 计算会话哈希(对应磁盘目录名) + */ + private md5Hash(text: string): string { + return crypto.createHash('md5').update(text).digest('hex') + } + + private async resolveHardlinkPath(accountDir: string, md5: string): Promise { + const dbPath = join(accountDir, 'db_storage', 'hardlink', 'hardlink.db') + if (!existsSync(dbPath)) { + this.logInfo('hardlink.db 不存在', { dbPath }) + return null + } + + try { + const ready = await this.ensureWcdbReady() + if (!ready) return null + + const tableResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'video_hardlink_info%' ORDER BY name DESC LIMIT 1") + + if (!tableResult.success || !tableResult.rows?.length) return null + const tableName = tableResult.rows[0].name + + const escapedMd5 = md5.replace(/'/g, "''") + const rowResult = await wcdbService.execQuery('media', dbPath, + `SELECT dir1, dir2, file_name FROM ${tableName} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`) + + if (!rowResult.success || !rowResult.rows?.length) return null + + const row = rowResult.rows[0] + const dir1 = row.dir1 ?? row.DIR1 + const dir2 = row.dir2 ?? row.DIR2 + const file_name = row.file_name ?? row.fileName ?? row.FILE_NAME + + if (dir1 === undefined || dir2 === undefined || !file_name) return null + + const dirTableResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1") + if (!dirTableResult.success || !dirTableResult.rows?.length) return null + const dirTable = dirTableResult.rows[0].name + + const getDirName = async (id: number) => { + const res = await wcdbService.execQuery('media', dbPath, `SELECT username FROM ${dirTable} WHERE rowid = ${id} LIMIT 1`) + return res.success && res.rows?.length ? String(res.rows[0].username) : null + } + + const dir1Name = await getDirName(Number(dir1)) + const dir2Name = await getDirName(Number(dir2)) + if (!dir1Name || !dir2Name) return null + + const candidates = [ + join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Video', file_name), + join(accountDir, 'msg', 'attach', dir1Name, dir2Name, file_name), + join(accountDir, 'msg', 'video', dir2Name, file_name) + ] + + for (const p of candidates) { + if (existsSync(p)) { + this.logInfo('hardlink 命中', { path: p }) + return p + } + } + } catch (e) { + this.logError('resolveHardlinkPath 异常', e) + } + return null + } + + private async searchVideoFile(accountDir: string, md5: string, sessionId?: string): Promise { + const lowerMd5 = md5.toLowerCase() + + // 策略 1: 基于 sessionId 哈希的精准搜索 (XWeChat 核心逻辑) + if (sessionId) { + const sessHash = this.md5Hash(sessionId) + const attachRoot = join(accountDir, 'msg', 'attach', sessHash) + if (existsSync(attachRoot)) { + try { + const monthDirs = readdirSync(attachRoot).filter(d => /^\d{4}-\d{2}$/.test(d)) + for (const m of monthDirs) { + const videoDir = join(attachRoot, m, 'Video') + if (existsSync(videoDir)) { + // 尝试精确名和带数字后缀的名 + const files = readdirSync(videoDir) + const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4')) + if (match) return join(videoDir, match) + } + } + } catch { } + } + } + + // 策略 2: 概率搜索所有 session 目录 (针对最近 3 个月) + const attachRoot = join(accountDir, 'msg', 'attach') + if (existsSync(attachRoot)) { + try { + const sessionDirs = readdirSync(attachRoot).filter(d => d.length === 32) + const now = new Date() + const months = [] + for (let i = 0; i < 3; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + } + + for (const sess of sessionDirs) { + for (const month of months) { + const videoDir = join(attachRoot, sess, month, 'Video') + if (existsSync(videoDir)) { + const files = readdirSync(videoDir) + const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4')) + if (match) return join(videoDir, match) + } + } + } + } catch { } + } + + // 策略 3: 传统 msg/video 目录 + const videoRoot = join(accountDir, 'msg', 'video') + if (existsSync(videoRoot)) { + try { + const monthDirs = readdirSync(videoRoot).sort().reverse() + for (const m of monthDirs) { + const dirPath = join(videoRoot, m) + const files = readdirSync(dirPath) + const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4')) + if (match) return join(dirPath, match) + } + } catch { } + } + + return null + } + + private getXorKey(): number | undefined { + const raw = this.configService.get('imageXorKey') + if (typeof raw === 'number') return raw + if (typeof raw === 'string') { + const t = raw.trim() + return t.toLowerCase().startsWith('0x') ? parseInt(t, 16) : parseInt(t, 10) } return undefined } - /** - * 将文件转换为 data URL - */ - private fileToDataUrl(filePath: string, mimeType: string): string | undefined { - try { - if (!existsSync(filePath)) return undefined - const buffer = readFileSync(filePath) - return `data:${mimeType};base64,${buffer.toString('base64')}` - } catch { - return undefined + private isEncrypted(buffer: Buffer, xorKey: number, type: 'video' | 'image'): boolean { + if (buffer.length < 8) return false + const first = buffer[0] ^ xorKey + const second = buffer[1] ^ xorKey + + if (type === 'image') { + return (first === 0xFF && second === 0xD8) || (first === 0x89 && second === 0x50) || (first === 0x47 && second === 0x49) + } else { + // MP4 头部通常包含 'ftyp' + const f = buffer[4] ^ xorKey + const t = buffer[5] ^ xorKey + const y = buffer[6] ^ xorKey + const p = buffer[7] ^ xorKey + return (f === 0x66 && t === 0x74 && y === 0x79 && p === 0x70) || // 'ftyp' + (buffer[0] ^ xorKey) === 0x00 && (buffer[1] ^ xorKey) === 0x00 // 一些 mp4 以 00 00 开头 } } - /** - * 根据视频MD5获取视频文件信息 - * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ - * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg - */ - async getVideoInfo(videoMd5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - - if (!dbPath || !wxid || !videoMd5) { - return { exists: false } - } - - // 先尝试从数据库查询真正的视频文件名 - const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 - - // 检查 dbPath 是否已经包含 wxid,避免重复拼接 - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxid = this.cleanWxid(wxid) - - let videoBaseDir: string - if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - // dbPath 已经包含 wxid,直接使用 - videoBaseDir = join(dbPath, 'msg', 'video') - } else { - // dbPath 不包含 wxid,需要拼接 - videoBaseDir = join(dbPath, wxid, 'msg', 'video') - } - - if (!existsSync(videoBaseDir)) { - return { exists: false } - } - - // 遍历年月目录查找视频文件 + private filePathToUrl(filePath: string): string { try { - const allDirs = readdirSync(videoBaseDir) + const { pathToFileURL } = require('url') + const url = pathToFileURL(filePath).toString() + const s = statSync(filePath) + return `${url}?v=${Math.floor(s.mtimeMs)}` + } catch { + return `file:///${filePath.replace(/\\/g, '/')}` + } + } - // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 - const yearMonthDirs = allDirs - .filter(dir => { - const dirPath = join(videoBaseDir, dir) - return statSync(dirPath).isDirectory() - }) - .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + private handleFile(filePath: string, type: 'video' | 'image', sessionId?: string): string | undefined { + if (!existsSync(filePath)) return undefined + const xorKey = this.getXorKey() + + try { + const buffer = readFileSync(filePath) + const isEnc = xorKey !== undefined && !Number.isNaN(xorKey) && this.isEncrypted(buffer, xorKey, type) - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - const coverPath = join(dirPath, `${realVideoMd5}.jpg`) - const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) - - // 检查视频文件是否存在 - if (existsSync(videoPath)) { - return { - videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), - exists: true + if (isEnc) { + const decrypted = Buffer.alloc(buffer.length) + for (let i = 0; i < buffer.length; i++) decrypted[i] = buffer[i] ^ xorKey! + + if (type === 'image') { + return `data:image/jpeg;base64,${decrypted.toString('base64')}` + } else { + const cacheDir = join(this.getCacheBasePath(), 'Videos', this.cleanWxid(sessionId || 'unknown')) + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }) + const outPath = join(cacheDir, `${basename(filePath)}`) + if (!existsSync(outPath) || statSync(outPath).size !== decrypted.length) { + writeFileSync(outPath, decrypted) } + return this.filePathToUrl(outPath) } } + + if (type === 'image') { + return `data:image/jpeg;base64,${buffer.toString('base64')}` + } + return this.filePathToUrl(filePath) } catch (e) { - // 忽略错误 + this.logError(`处理${type}文件异常: ${filePath}`, e) + return type === 'image' ? undefined : this.filePathToUrl(filePath) + } + } + + async getVideoInfo(videoMd5: string, sessionId?: string): Promise { + this.logInfo('获取视频信息', { videoMd5, sessionId }) + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + if (!dbPath || !wxid || !videoMd5) return { exists: false } + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) { + this.logError('未找到账号目录', undefined, { dbPath, wxid }) + return { exists: false } } + // 1. 通过 hardlink 映射 + let videoPath = await this.resolveHardlinkPath(accountDir, videoMd5) + + // 2. 启发式搜索 + if (!videoPath) { + videoPath = await this.searchVideoFile(accountDir, videoMd5, sessionId) + } + + if (videoPath && existsSync(videoPath)) { + this.logInfo('定位成功', { videoPath }) + const base = videoPath.slice(0, -4) + const coverPath = `${base}.jpg` + const thumbPath = `${base}_thumb.jpg` + + return { + videoUrl: this.handleFile(videoPath, 'video', sessionId), + coverUrl: this.handleFile(coverPath, 'image', sessionId), + thumbUrl: this.handleFile(thumbPath, 'image', sessionId), + exists: true + } + } + + this.logInfo('定位失败', { videoMd5 }) return { exists: false } } - /** - * 根据消息内容解析视频MD5 - */ parseVideoMd5(content: string): string | undefined { - - // 打印前500字符看看 XML 结构 - if (!content) return undefined - try { - // 提取所有可能的 md5 值进行日志 - const allMd5s: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi - let match - while ((match = md5Regex.exec(content)) !== null) { - allMd5s.push(`${match[0]}`) - } - - // 提取 md5(用于查询 hardlink.db) - // 注意:不是 rawmd5,rawmd5 是另一个值 - // 格式: md5="xxx" 或 xxx - - // 尝试从videomsg标签中提取md5 - const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMatch) { - return videoMsgMatch[1].toLowerCase() - } - - const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (attrMatch) { - return attrMatch[1].toLowerCase() - } - - const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5Match) { - return md5Match[1].toLowerCase() - } - } catch (e) { - console.error('[VideoService] 解析视频MD5失败:', e) - } - - return undefined + const m = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || + /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || + /([a-fA-F0-9]+)<\/md5>/i.exec(content) + return m ? m[1].toLowerCase() : undefined + } catch { return undefined } } } diff --git a/package-lock.json b/package-lock.json index 92d10ba..4ac3dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2909,6 +2910,7 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3055,6 +3057,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3994,6 +3997,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5103,6 +5107,7 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5290,6 +5295,7 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5376,7 +5382,6 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5390,7 +5395,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5406,7 +5410,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5420,7 +5423,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9150,6 +9152,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9159,6 +9162,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9593,6 +9597,7 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10434,6 +10439,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10881,6 +10887,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10970,7 +10977,8 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -10996,6 +11004,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b5ec3df..af71340 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3543,7 +3543,7 @@ function MessageBubble({ videoLoadingRef.current = true setVideoLoading(true) try { - const result = await window.electronAPI.video.getVideoInfo(videoMd5) + const result = await window.electronAPI.video.getVideoInfo(videoMd5, session.username) if (result && result.success && result.exists) { setVideoInfo({ exists: result.exists, @@ -3560,7 +3560,7 @@ function MessageBubble({ videoLoadingRef.current = false setVideoLoading(false) } - }, [videoMd5]) + }, [videoMd5, session.username]) // 视频进入视野时自动加载 useEffect(() => { From 5e96cdb1d6df746f5c578be277f6e481bddb261f Mon Sep 17 00:00:00 2001 From: ace Date: Fri, 27 Feb 2026 16:07:27 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=BA=86=E5=8E=9F?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E8=A7=86=E9=A2=91=E8=A7=A3=E5=AF=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 4 +- electron/preload.ts | 11 +- electron/services/imageDecryptService.ts | 524 +++++++---------------- electron/services/videoService.ts | 519 +++++++++------------- src/pages/ChatPage.tsx | 15 +- 5 files changed, 381 insertions(+), 692 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index f9d3bcd..ab9128d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -852,9 +852,9 @@ function registerIpcHandlers() { }) // 视频相关 - ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, sessionId?: string) => { + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { try { - const result = await videoService.getVideoInfo(videoMd5, sessionId) + const result = await videoService.getVideoInfo(videoMd5) return { success: true, ...result } } catch (e) { return { success: false, error: String(e), exists: false } diff --git a/electron/preload.ts b/electron/preload.ts index d03b94e..91bb728 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -194,12 +194,11 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, - // 视频 - video: { - getVideoInfo: (videoMd5: string, sessionId?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, sessionId), - parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) - }, - + // 视频 + video: { + getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) + }, // 数据分析 analytics: { getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 20f034b..6592030 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow } from 'electron' import { basename, dirname, extname, join } from 'path' import { pathToFileURL } from 'url' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' @@ -8,19 +8,9 @@ import { Worker } from 'worker_threads' import { ConfigService } from './config' import { wcdbService } from './wcdbService' -// 获取 ffmpeg-static 的路径 -function getStaticFfmpegPath(): string | null { +// 鑾峰彇 ffmpeg-static 鐨勮矾寰?function getStaticFfmpegPath(): string | null { try { - // 优先处理打包后的路径 - if (app.isPackaged) { - const resourcesPath = process.resourcesPath - const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') - if (existsSync(packedPath)) { - return packedPath - } - } - - // 方法1: 直接 require ffmpeg-static(开发环境) + // 鏂规硶1: 鐩存帴 require ffmpeg-static // eslint-disable-next-line @typescript-eslint/no-var-requires const ffmpegStatic = require('ffmpeg-static') @@ -28,12 +18,21 @@ function getStaticFfmpegPath(): string | null { return ffmpegStatic } - // 方法2: 手动构建路径(开发环境备用) + // 鏂规硶2: 鎵嬪姩鏋勫缓璺緞锛堝紑鍙戠幆澧冿級 const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') if (existsSync(devPath)) { return devPath } + // 鏂规硶3: 鎵撳寘鍚庣殑璺緞 + if (app.isPackaged) { + const resourcesPath = process.resourcesPath + const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') + if (existsSync(packedPath)) { + return packedPath + } + } + return null } catch { return null @@ -44,9 +43,7 @@ type DecryptResult = { success: boolean localPath?: string error?: string - isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) - liveVideoPath?: string // 实况照片的视频路径 -} + isThumb?: boolean // 鏄惁鏄缉鐣ュ浘锛堟病鏈夐珮娓呭浘鏃惰繑鍥炵缉鐣ュ浘锛?} type HardlinkState = { imageTable?: string @@ -62,7 +59,6 @@ export class ImageDecryptService { private cacheIndexed = false private cacheIndexing: Promise | null = null private updateFlags = new Map() - private noLiveSet = new Set() // 已确认无 live 视频的图片路径 private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return @@ -70,8 +66,7 @@ export class ImageDecryptService { const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` - // 只写入文件,不输出到控制台 - this.writeLog(logLine) + // 鍙啓鍏ユ枃浠讹紝涓嶈緭鍑哄埌鎺у埗鍙? this.writeLog(logLine) } private logError(message: string, error?: unknown, meta?: Record): void { @@ -81,10 +76,10 @@ export class ImageDecryptService { const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` - // 同时输出到控制台 + // 鍚屾椂杈撳嚭鍒版帶鍒跺彴 console.error(message, error, meta) - // 写入日志文件 + // 鍐欏叆鏃ュ織鏂囦欢 this.writeLog(logLine) } @@ -96,7 +91,7 @@ export class ImageDecryptService { } appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) } catch (err) { - console.error('写入日志失败:', err) + console.error('鍐欏叆鏃ュ織澶辫触:', err) } } @@ -105,7 +100,7 @@ export class ImageDecryptService { const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { - return { success: false, error: '缺少图片标识' } + return { success: false, error: '缂哄皯鍥剧墖鏍囪瘑' } } for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) @@ -118,9 +113,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath } + return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -139,31 +133,19 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath } + return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } } } - this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '未找到缓存图片' } + this.logInfo('鏈壘鍒扮紦瀛?, { md5: payload.imageMd5, datName: payload.imageDatName }) + return { success: false, error: '鏈壘鍒扮紦瀛樺浘鐗? } } async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { await this.ensureCacheIndexed() const cacheKey = payload.imageMd5 || payload.imageDatName if (!cacheKey) { - return { success: false, error: '缺少图片标识' } - } - - if (payload.force) { - const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId) - if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) { - const dataUrl = this.fileToDataUrl(hdCached) - const localPath = dataUrl || this.filePathToUrl(hdCached) - const liveVideoPath = this.checkLiveVideoCache(hdCached) - this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb: false, liveVideoPath } - } + return { success: false, error: '缂哄皯鍥剧墖鏍囪瘑' } } if (!payload.force) { @@ -171,9 +153,8 @@ export class ImageDecryptService { if (cached && existsSync(cached) && this.isImageFile(cached)) { const dataUrl = this.fileToDataUrl(cached) const localPath = dataUrl || this.filePathToUrl(cached) - const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, liveVideoPath } + return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(cacheKey) @@ -196,19 +177,19 @@ export class ImageDecryptService { payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, cacheKey: string ): Promise { - this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) + this.logInfo('寮€濮嬭В瀵嗗浘鐗?, { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) { - this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) - return { success: false, error: '未配置账号或数据库路径' } + this.logError('閰嶇疆缂哄け', undefined, { wxid: !!wxid, dbPath: !!dbPath }) + return { success: false, error: '鏈厤缃处鍙锋垨鏁版嵁搴撹矾寰? } } const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) { - this.logError('未找到账号目录', undefined, { dbPath, wxid }) - return { success: false, error: '未找到账号目录' } + this.logError('鏈壘鍒拌处鍙风洰褰?, undefined, { dbPath, wxid }) + return { success: false, error: '鏈壘鍒拌处鍙风洰褰? } } const datPath = await this.resolveDatPath( @@ -219,17 +200,17 @@ export class ImageDecryptService { { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } ) - // 如果要求高清图但没找到,直接返回提示 + // 濡傛灉瑕佹眰楂樻竻鍥句絾娌℃壘鍒帮紝鐩存帴杩斿洖鎻愮ず if (!datPath && payload.force) { - this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } + this.logError('鏈壘鍒伴珮娓呭浘', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) + return { success: false, error: '鏈壘鍒伴珮娓呭浘锛岃鍦ㄥ井淇′腑鐐瑰紑璇ュ浘鐗囨煡鐪嬪悗閲嶈瘯' } } if (!datPath) { - this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '未找到图片文件' } + this.logError('鏈壘鍒癉AT鏂囦欢', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) + return { success: false, error: '鏈壘鍒板浘鐗囨枃浠? } } - this.logInfo('找到DAT文件', { datPath }) + this.logInfo('鎵惧埌DAT鏂囦欢', { datPath }) if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) @@ -240,26 +221,24 @@ export class ImageDecryptService { return { success: true, localPath, isThumb } } - // 查找已缓存的解密文件 + // 鏌ユ壘宸茬紦瀛樼殑瑙e瘑鏂囦欢 const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) if (existing) { - this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) + this.logInfo('鎵惧埌宸茶В瀵嗘枃浠?, { existing, isHd: this.isHdPath(existing) }) const isHd = this.isHdPath(existing) - // 如果要求高清但找到的是缩略图,继续解密高清图 + // 濡傛灉瑕佹眰楂樻竻浣嗘壘鍒扮殑鏄缉鐣ュ浘锛岀户缁В瀵嗛珮娓呭浘 if (!(payload.force && !isHd)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) const dataUrl = this.fileToDataUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) - const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb, liveVideoPath } + return { success: true, localPath, isThumb } } } const xorKeyRaw = this.configService.get('imageXorKey') as unknown - // 支持十六进制格式(如 0x53)和十进制格式 - let xorKey: number + // 鏀寔鍗佸叚杩涘埗鏍煎紡锛堝 0x53锛夊拰鍗佽繘鍒舵牸寮? let xorKey: number if (typeof xorKeyRaw === 'number') { xorKey = xorKeyRaw } else { @@ -271,23 +250,21 @@ export class ImageDecryptService { } } if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { - return { success: false, error: '未配置图片解密密钥' } + return { success: false, error: '鏈厤缃浘鐗囪В瀵嗗瘑閽? } } const aesKeyRaw = this.configService.get('imageAesKey') const aesKey = this.resolveAesKey(aesKeyRaw) - this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) + this.logInfo('寮€濮嬭В瀵咲AT鏂囦欢', { datPath, xorKey, hasAesKey: !!aesKey }) let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) - // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 - const wxgfResult = await this.unwrapWxgf(decrypted) + // 妫€鏌ユ槸鍚︽槸 wxgf 鏍煎紡锛屽鏋滄槸鍒欏皾璇曟彁鍙栫湡瀹炲浘鐗囨暟鎹? const wxgfResult = await this.unwrapWxgf(decrypted) decrypted = wxgfResult.data let ext = this.detectImageExtension(decrypted) - // 如果是 wxgf 格式且没检测到扩展名 - if (wxgfResult.isWxgf && !ext) { + // 濡傛灉鏄?wxgf 鏍煎紡涓旀病妫€娴嬪埌鎵╁睍鍚? if (wxgfResult.isWxgf && !ext) { ext = '.hevc' } @@ -295,13 +272,12 @@ export class ImageDecryptService { const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) await writeFile(outputPath, decrypted) - this.logInfo('解密成功', { outputPath, size: decrypted.length }) + this.logInfo('瑙e瘑鎴愬姛', { outputPath, size: decrypted.length }) - // 对于 hevc 格式,返回错误提示 - if (finalExt === '.hevc') { + // 瀵逛簬 hevc 鏍煎紡锛岃繑鍥為敊璇彁绀? if (finalExt === '.hevc') { return { success: false, - error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示', + error: '姝ゅ浘鐗囦负寰俊鏂版牸寮?wxgf)锛岄渶瑕佸畨瑁?ffmpeg 鎵嶈兘鏄剧ず', isThumb: this.isThumbnailPath(datPath) } } @@ -313,131 +289,58 @@ export class ImageDecryptService { const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const localPath = dataUrl || this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) - - // 检测实况照片(Motion Photo) - let liveVideoPath: string | undefined - if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) { - const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted) - if (videoPath) liveVideoPath = this.filePathToUrl(videoPath) - } - - return { success: true, localPath, isThumb, liveVideoPath } + return { success: true, localPath, isThumb } } catch (e) { - this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) + this.logError('瑙e瘑澶辫触', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } } } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() + const cleanedWxid = this.cleanAccountDirName(wxid) const normalized = dbPath.replace(/[\\/]+$/, '') - const candidates: { path: string; mtime: number }[] = [] - - // 检查直接路径 const direct = join(normalized, cleanedWxid) - if (existsSync(direct) && this.isAccountDir(direct)) { - candidates.push({ path: direct, mtime: this.getDirMtime(direct) }) - } + if (existsSync(direct)) return direct - // 检查 dbPath 本身是否就是账号目录 - if (this.isAccountDir(normalized)) { - candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) - } + if (this.isAccountDir(normalized)) return normalized - // 扫描 dbPath 下的所有子目录寻找匹配的 wxid try { - if (existsSync(normalized) && this.isDirectory(normalized)) { - const entries = readdirSync(normalized) - for (const entry of entries) { - const entryPath = join(normalized, entry) - if (!this.isDirectory(entryPath)) continue - - const lowerEntry = entry.toLowerCase() - // 匹配原 wxid 或带有后缀的 wxid (如 wxid_xxx_1234) - if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { - if (this.isAccountDir(entryPath)) { - if (!candidates.some(c => c.path === entryPath)) { - candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) }) - } - } - } + const entries = readdirSync(normalized) + const lowerWxid = cleanedWxid.toLowerCase() + for (const entry of entries) { + const entryPath = join(normalized, entry) + if (!this.isDirectory(entryPath)) continue + const lowerEntry = entry.toLowerCase() + if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { + if (this.isAccountDir(entryPath)) return entryPath } } } catch { } - if (candidates.length === 0) return null - - // 按修改时间降序排序,取最新的(最可能是当前活跃的) - candidates.sort((a, b) => b.mtime - a.mtime) - - if (candidates.length > 1) { - this.logInfo('找到多个候选账号目录,选择最新修改的一个', { - selected: candidates[0].path, - all: candidates.map(c => c.path) - }) - } - - return candidates[0].path - } - - private getDirMtime(dirPath: string): number { - try { - const stat = statSync(dirPath) - let mtime = stat.mtimeMs - - // 检查几个关键子目录的修改时间,以更准确地反映活动状态 - const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image'] - for (const sub of subDirs) { - const fullPath = join(dirPath, sub) - if (existsSync(fullPath)) { - try { - mtime = Math.max(mtime, statSync(fullPath).mtimeMs) - } catch { } - } - } - - return mtime - } catch { - return 0 - } + return null } /** - * 获取解密后的缓存目录(用于查找 hardlink.db) - */ + * 鑾峰彇瑙e瘑鍚庣殑缂撳瓨鐩綍锛堢敤浜庢煡鎵?hardlink.db锛? */ private getDecryptedCacheDir(wxid: string): string | null { + const cachePath = this.configService.get('cachePath') + if (!cachePath) return null + const cleanedWxid = this.cleanAccountDirName(wxid) - const configured = this.configService.get('cachePath') - const documentsPath = app.getPath('documents') - const baseCandidates = Array.from(new Set([ - configured || '', - join(documentsPath, 'WeFlow'), - join(documentsPath, 'WeFlowData'), - this.configService.getCacheBasePath() - ].filter(Boolean))) + const cacheAccountDir = join(cachePath, cleanedWxid) - for (const base of baseCandidates) { - const accountCandidates = Array.from(new Set([ - join(base, wxid), - join(base, cleanedWxid), - join(base, 'databases', wxid), - join(base, 'databases', cleanedWxid) - ])) - for (const accountDir of accountCandidates) { - if (existsSync(join(accountDir, 'hardlink.db'))) { - return accountDir - } - const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink') - if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) { - return hardlinkSubdir - } - } - if (existsSync(join(base, 'hardlink.db'))) { - return base - } + // 妫€鏌ョ紦瀛樼洰褰曚笅鏄惁鏈?hardlink.db + if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { + return cacheAccountDir + } + if (existsSync(join(cachePath, 'hardlink.db'))) { + return cachePath + } + const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') + if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { + return cacheHardlinkDir } - return null } @@ -446,8 +349,7 @@ export class ImageDecryptService { existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) || - existsSync(join(dirPath, 'msg', 'attach')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) ) } @@ -493,7 +395,7 @@ export class ImageDecryptService { skipResolvedCache }) - // 优先通过 hardlink.db 查询 + // 浼樺厛閫氳繃 hardlink.db 鏌ヨ if (imageMd5) { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) @@ -505,22 +407,15 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 找到的是缩略图,但要求高清图 - // 尝试在同一目录下查找高清图变体(快速查找,不遍历) + // hardlink 鎵惧埌鐨勬槸缂╃暐鍥撅紝浣嗚姹傞珮娓呭浘 + // 灏濊瘯鍦ㄥ悓涓€鐩綍涓嬫煡鎵鹃珮娓呭浘鍙樹綋锛堝揩閫熸煡鎵撅紝涓嶉亶鍘嗭級 const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageMd5, hdInDir) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } - // 没找到高清图,返回 null(不进行全局搜索) - return null + // 娌℃壘鍒伴珮娓呭浘锛岃繑鍥?null锛堜笉杩涜鍏ㄥ眬鎼滅储锛? return null } this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { @@ -533,19 +428,12 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, fallbackPath) return fallbackPath } - // 找到缩略图但要求高清图,尝试同目录查找高清图变体 + // 鎵惧埌缂╃暐鍥句絾瑕佹眰楂樻竻鍥撅紝灏濊瘯鍚岀洰褰曟煡鎵鹃珮娓呭浘鍙樹綋 const hdPath = this.findHdVariantInSameDir(fallbackPath) if (hdPath) { - this.cacheDatPath(accountDir, imageMd5, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageMd5, hdInDir) - this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) @@ -562,34 +450,30 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 找到的是缩略图,但要求高清图 + // hardlink 鎵惧埌鐨勬槸缂╃暐鍥撅紝浣嗚姹傞珮娓呭浘 const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false) - if (hdInDir) { - this.cacheDatPath(accountDir, imageDatName, hdInDir) - return hdInDir - } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图 + // 濡傛灉瑕佹眰楂樻竻鍥句絾 hardlink 娌℃壘鍒帮紝涔熶笉瑕佹悳绱簡锛堟悳绱㈠お鎱級 + if (!allowThumbnail) { + return null + } if (!imageDatName) return null if (!skipResolvedCache) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { if (allowThumbnail || !this.isThumbnailPath(cached)) return cached - // 缓存的是缩略图,尝试找高清图 + // 缂撳瓨鐨勬槸缂╃暐鍥撅紝灏濊瘯鎵鹃珮娓呭浘 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath - const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) - if (hdInDir) return hdInDir } } @@ -759,14 +643,12 @@ export class ImageDecryptService { } } - // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 - let dir1Name: string | null = null + // dir1 鍜?dir2 鏄?rowid锛岄渶瑕佷粠 dir2id 琛ㄦ煡璇㈠搴旂殑鐩綍鍚? let dir1Name: string | null = null let dir2Name: string | null = null if (state.dirTable) { try { - // 通过 rowid 查询目录名 - const dir1Result = await wcdbService.execQuery( + // 閫氳繃 rowid 鏌ヨ鐩綍鍚? const dir1Result = await wcdbService.execQuery( 'media', hardlinkPath, `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` @@ -795,7 +677,7 @@ export class ImageDecryptService { return null } - // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} + // 鏋勫缓璺緞: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} const possiblePaths = [ join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), @@ -885,16 +767,15 @@ export class ImageDecryptService { const root = join(accountDir, 'msg', 'attach') if (!existsSync(root)) return null - // 优化1:快速概率性查找 - // 包含:1. 基于文件名的前缀猜测 (旧版) - // 2. 基于日期的最近月份扫描 (新版无索引时) + // 浼樺寲1锛氬揩閫熸鐜囨€ф煡鎵? // 鍖呭惈锛?. 鍩轰簬鏂囦欢鍚嶇殑鍓嶇紑鐚滄祴 (鏃х増) + // 2. 鍩轰簬鏃ユ湡鐨勬渶杩戞湀浠芥壂鎻?(鏂扮増鏃犵储寮曟椂) const fastHit = await this.fastProbabilisticSearch(root, datName) if (fastHit) { this.resolvedCache.set(key, fastHit) return fastHit } - // 优化2:兜底扫描 (异步非阻塞) + // 浼樺寲2锛氬厹搴曟壂鎻?(寮傛闈為樆濉? const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) if (found) { this.resolvedCache.set(key, found) @@ -904,16 +785,15 @@ export class ImageDecryptService { } /** - * 基于文件名的哈希特征猜测可能的路径 - * 包含:1. 微信旧版结构 filename.substr(0, 2)/... - * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename + * 鍩轰簬鏂囦欢鍚嶇殑鍝堝笇鐗瑰緛鐚滄祴鍙兘鐨勮矾寰? * 鍖呭惈锛?. 寰俊鏃х増缁撴瀯 filename.substr(0, 2)/... + * 2. 寰俊鏂扮増缁撴瀯 msg/attach/{hash}/{YYYY-MM}/Img/filename */ private async fastProbabilisticSearch(root: string, datName: string): Promise { const { promises: fs } = require('fs') const { join } = require('path') try { - // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- + // --- 绛栫暐 A: 鏃х増璺緞鐚滄祴 (msg/attach/xx/yy/...) --- const lowerName = datName.toLowerCase() let baseName = lowerName if (baseName.endsWith('.dat')) { @@ -944,7 +824,7 @@ export class ImageDecryptService { } catch { } } - // --- 策略 B: 新版 Session 哈希路径猜测 --- + // --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 --- try { const entries = await fs.readdir(root, { withFileTypes: true }) const sessionDirs = entries @@ -996,16 +876,15 @@ export class ImageDecryptService { } /** - * 在同一目录下查找高清图变体 - * 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat + * 鍦ㄥ悓涓€鐩綍涓嬫煡鎵鹃珮娓呭浘鍙樹綋 + * 缂╃暐鍥? xxx_t.dat -> 楂樻竻鍥? xxx_h.dat 鎴?xxx.dat */ private findHdVariantInSameDir(thumbPath: string): string | null { try { const dir = dirname(thumbPath) const fileName = basename(thumbPath).toLowerCase() - // 提取基础名称(去掉 _t.dat 或 .t.dat) - let baseName = fileName + // 鎻愬彇鍩虹鍚嶇О锛堝幓鎺?_t.dat 鎴?.t.dat锛? let baseName = fileName if (baseName.endsWith('_t.dat')) { baseName = baseName.slice(0, -6) } else if (baseName.endsWith('.t.dat')) { @@ -1014,8 +893,7 @@ export class ImageDecryptService { return null } - // 尝试查找高清图变体 - const variants = [ + // 灏濊瘯鏌ユ壘楂樻竻鍥惧彉浣? const variants = [ `${baseName}_h.dat`, `${baseName}.h.dat`, `${baseName}.dat` @@ -1109,7 +987,7 @@ export class ImageDecryptService { if (this.isThumbnailDat(lower)) return true const ext = extname(lower) const base = ext ? lower.slice(0, -ext.length) : lower - // 支持新命名 _thumb 和旧命名 _t + // 鏀寔鏂板懡鍚?_thumb 鍜屾棫鍛藉悕 _t return base.endsWith('_t') || base.endsWith('_thumb') } @@ -1165,9 +1043,8 @@ export class ImageDecryptService { const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] - // 遍历所有可能的缓存根路径 - for (const root of allRoots) { - // 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg + // 閬嶅巻鎵€鏈夊彲鑳界殑缂撳瓨鏍硅矾寰? for (const root of allRoots) { + // 绛栫暐1: 鏂扮洰褰曠粨鏋?Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg if (sessionId) { const sessionDir = join(root, this.sanitizeDirName(sessionId)) if (existsSync(sessionDir)) { @@ -1176,7 +1053,7 @@ export class ImageDecryptService { .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) .map(d => d.name) .sort() - .reverse() // 最新的日期优先 + .reverse() // 鏈€鏂扮殑鏃ユ湡浼樺厛 for (const dateDir of dateDirs) { const imageDir = join(sessionDir, dateDir) @@ -1187,15 +1064,14 @@ export class ImageDecryptService { } } - // 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId) - try { + // 绛栫暐2: 閬嶅巻鎵€鏈?sessionId 鐩綍鏌ユ壘锛堝鏋滄病鏈夋寚瀹?sessionId锛? try { const sessionDirs = readdirSync(root, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name) for (const session of sessionDirs) { const sessionDir = join(root, session) - // 检查是否是日期目录结构 + // 妫€鏌ユ槸鍚︽槸鏃ユ湡鐩綍缁撴瀯 try { const subDirs = readdirSync(sessionDir, { withFileTypes: true }) .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) @@ -1210,14 +1086,14 @@ export class ImageDecryptService { } } catch { } - // 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg + // 绛栫暐3: 鏃х洰褰曠粨鏋?Images/{normalizedKey}/{normalizedKey}_thumb.jpg const oldImageDir = join(root, normalizedKey) if (existsSync(oldImageDir)) { const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd) if (hit) return hit } - // 策略4: 最旧的平铺结构 Images/{file}.jpg + // 绛栫暐4: 鏈€鏃х殑骞抽摵缁撴瀯 Images/{file}.jpg for (const ext of extensions) { const candidate = join(root, `${cacheKey}${ext}`) if (existsSync(candidate)) return candidate @@ -1237,8 +1113,7 @@ export class ImageDecryptService { extensions: string[], preferHd: boolean ): string | null { - // 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的) - const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`) + // 鍏堟鏌ュ苟鍒犻櫎鏃х殑 .hevc 鏂囦欢锛坒fmpeg 杞崲澶辫触鏃堕仐鐣欑殑锛? const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`) const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`) try { if (existsSync(hevcThumb)) { @@ -1257,8 +1132,7 @@ export class ImageDecryptService { const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) if (existsSync(thumbPath)) return thumbPath - // 允许返回 _hd 格式(因为它有 _hd 变体后缀) - if (!preferHd) { + // 鍏佽杩斿洖 _hd 鏍煎紡锛堝洜涓哄畠鏈?_hd 鍙樹綋鍚庣紑锛? if (!preferHd) { const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) if (existsSync(hdPath)) return hdPath } @@ -1271,11 +1145,9 @@ export class ImageDecryptService { const lower = name.toLowerCase() const base = lower.endsWith('.dat') ? name.slice(0, -4) : name - // 提取基础名称(去掉 _t, _h 等后缀) - const normalizedBase = this.normalizeDatBase(base) + // 鎻愬彇鍩虹鍚嶇О锛堝幓鎺?_t, _h 绛夊悗缂€锛? const normalizedBase = this.normalizeDatBase(base) - // 判断是缩略图还是高清图 - const isThumb = this.isThumbnailDat(lower) + // 鍒ゆ柇鏄缉鐣ュ浘杩樻槸楂樻竻鍥? const isThumb = this.isThumbnailDat(lower) const suffix = isThumb ? '_thumb' : '_hd' const contactDir = this.sanitizeDirName(sessionId || 'unknown') @@ -1356,7 +1228,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) continue if (this.isThumbnailDat(lower)) continue const baseLower = lower.slice(0, -4) - // 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的) + // 鍙帓闄ゆ病鏈?_x 鍙樹綋鍚庣紑鐨勬枃浠讹紙鍏佽 _hd銆乢h 绛夋墍鏈夊甫鍙樹綋鐨勶級 if (!this.hasXVariant(baseLower)) continue if (this.normalizeDatBase(baseLower) !== target) continue return join(dirPath, entry) @@ -1369,7 +1241,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) - // 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的) + // 鍙鏌ユ槸鍚︽湁 _x 鍙樹綋鍚庣紑锛堝厑璁?_hd銆乢h 绛夋墍鏈夊甫鍙樹綋鐨勶級 return this.hasXVariant(baseLower) } @@ -1395,19 +1267,18 @@ export class ImageDecryptService { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing this.cacheIndexing = new Promise((resolve) => { - // 扫描所有可能的缓存根目录 - const allRoots = this.getAllCacheRoots() - this.logInfo('开始索引缓存', { roots: allRoots.length }) + // 鎵弿鎵€鏈夊彲鑳界殑缂撳瓨鏍圭洰褰? const allRoots = this.getAllCacheRoots() + this.logInfo('寮€濮嬬储寮曠紦瀛?, { roots: allRoots.length }) for (const root of allRoots) { try { - this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构 + this.indexCacheDir(root, 3, 0) // 澧炲姞娣卞害鍒?锛屾敮鎸?sessionId/YYYY-MM 缁撴瀯 } catch (e) { - this.logError('索引目录失败', e, { root }) + this.logError('绱㈠紩鐩綍澶辫触', e, { root }) } } - this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) + this.logInfo('缂撳瓨绱㈠紩瀹屾垚', { entries: this.resolvedCache.size }) this.cacheIndexed = true this.cacheIndexing = null resolve() @@ -1416,32 +1287,30 @@ export class ImageDecryptService { } /** - * 获取所有可能的缓存根路径(用于查找已缓存的图片) - * 包含当前路径、配置路径、旧版本路径 + * 鑾峰彇鎵€鏈夊彲鑳界殑缂撳瓨鏍硅矾寰勶紙鐢ㄤ簬鏌ユ壘宸茬紦瀛樼殑鍥剧墖锛? * 鍖呭惈褰撳墠璺緞銆侀厤缃矾寰勩€佹棫鐗堟湰璺緞 */ private getAllCacheRoots(): string[] { const roots: string[] = [] const configured = this.configService.get('cachePath') const documentsPath = app.getPath('documents') - // 主要路径(当前使用的) - const mainRoot = this.getCacheRoot() + // 涓昏璺緞锛堝綋鍓嶄娇鐢ㄧ殑锛? const mainRoot = this.getCacheRoot() roots.push(mainRoot) - // 如果配置了自定义路径,也检查其下的 Images + // 濡傛灉閰嶇疆浜嗚嚜瀹氫箟璺緞锛屼篃妫€鏌ュ叾涓嬬殑 Images if (configured) { roots.push(join(configured, 'Images')) roots.push(join(configured, 'images')) } - // 默认路径 + // 榛樿璺緞 roots.push(join(documentsPath, 'WeFlow', 'Images')) roots.push(join(documentsPath, 'WeFlow', 'images')) - // 兼容旧路径(如果有的话) + // 鍏煎鏃ц矾寰勶紙濡傛灉鏈夌殑璇濓級 roots.push(join(documentsPath, 'WeFlowData', 'Images')) - // 去重并过滤存在的路径 + // 鍘婚噸骞惰繃婊ゅ瓨鍦ㄧ殑璺緞 const uniqueRoots = Array.from(new Set(roots)) const existingRoots = uniqueRoots.filter(r => existsSync(r)) @@ -1523,14 +1392,14 @@ export class ImageDecryptService { } // version === 2 if (!aesKey || aesKey.length !== 16) { - throw new Error('请到设置配置图片解密密钥') + throw new Error('璇峰埌璁剧疆閰嶇疆鍥剧墖瑙e瘑瀵嗛挜') } return this.decryptDatV4(datPath, xorKey, aesKey) } private getDatVersion(inputPath: string): number { if (!existsSync(inputPath)) { - throw new Error('文件不存在') + throw new Error('鏂囦欢涓嶅瓨鍦?) } const bytes = readFileSync(inputPath) if (bytes.length < 6) { @@ -1558,7 +1427,7 @@ export class ImageDecryptService { private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer { const bytes = readFileSync(inputPath) if (bytes.length < 0x0f) { - throw new Error('文件太小,无法解析') + throw new Error('鏂囦欢澶皬锛屾棤娉曡В鏋?) } const header = bytes.subarray(0, 0x0f) @@ -1566,13 +1435,11 @@ export class ImageDecryptService { const aesSize = this.bytesToInt32(header.subarray(6, 10)) const xorSize = this.bytesToInt32(header.subarray(10, 14)) - // AES 数据需要对齐到 16 字节(PKCS7 填充) - // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 - const remainder = ((aesSize % 16) + 16) % 16 + // AES 鏁版嵁闇€瑕佸榻愬埌 16 瀛楄妭锛圥KCS7 濉厖锛? // 褰?aesSize % 16 === 0 鏃讹紝浠嶉渶瑕侀澶?16 瀛楄妭鐨勫~鍏? const remainder = ((aesSize % 16) + 16) % 16 const alignedAesSize = aesSize + (16 - remainder) if (alignedAesSize > data.length) { - throw new Error('文件格式异常:AES 数据长度超过文件实际长度') + throw new Error('鏂囦欢鏍煎紡寮傚父锛欰ES 鏁版嵁闀垮害瓒呰繃鏂囦欢瀹為檯闀垮害') } const aesData = data.subarray(0, alignedAesSize) @@ -1582,13 +1449,13 @@ export class ImageDecryptService { decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) - // 使用 PKCS7 填充移除 + // 浣跨敤 PKCS7 濉厖绉婚櫎 unpadded = this.strictRemovePadding(decrypted) } const remaining = data.subarray(alignedAesSize) if (xorSize < 0 || xorSize > remaining.length) { - throw new Error('文件格式异常:XOR 数据长度不合法') + throw new Error('鏂囦欢鏍煎紡寮傚父锛歑OR 鏁版嵁闀垮害涓嶅悎娉?) } let rawData = Buffer.alloc(0) @@ -1596,7 +1463,7 @@ export class ImageDecryptService { if (xorSize > 0) { const rawLength = remaining.length - xorSize if (rawLength < 0) { - throw new Error('文件格式异常:原始数据长度小于XOR长度') + throw new Error('鏂囦欢鏍煎紡寮傚父锛氬師濮嬫暟鎹暱搴﹀皬浜嶺OR闀垮害') } rawData = remaining.subarray(0, rawLength) const xorData = remaining.subarray(rawLength) @@ -1614,29 +1481,29 @@ export class ImageDecryptService { private bytesToInt32(bytes: Buffer): number { if (bytes.length !== 4) { - throw new Error('需要4个字节') + throw new Error('闇€瑕?涓瓧鑺?) } return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) } asciiKey16(keyString: string): Buffer { if (keyString.length < 16) { - throw new Error('AES密钥至少需要16个字符') + throw new Error('AES瀵嗛挜鑷冲皯闇€瑕?6涓瓧绗?) } return Buffer.from(keyString, 'ascii').subarray(0, 16) } private strictRemovePadding(data: Buffer): Buffer { if (!data.length) { - throw new Error('解密结果为空,填充非法') + throw new Error('瑙e瘑缁撴灉涓虹┖锛屽~鍏呴潪娉?) } const paddingLength = data[data.length - 1] if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) { - throw new Error('PKCS7 填充长度非法') + throw new Error('PKCS7 濉厖闀垮害闈炴硶') } for (let i = data.length - paddingLength; i < data.length; i += 1) { if (data[i] !== paddingLength) { - throw new Error('PKCS7 填充内容非法') + throw new Error('PKCS7 濉厖鍐呭闈炴硶') } } return data.subarray(0, data.length - paddingLength) @@ -1711,7 +1578,7 @@ export class ImageDecryptService { return true } - // 保留原有的批量检测 XOR 密钥方法(用于兼容) + // 淇濈暀鍘熸湁鐨勬壒閲忔娴?XOR 瀵嗛挜鏂规硶锛堢敤浜庡吋瀹癸級 async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise { const keyCount: Map = new Map() let filesChecked = 0 @@ -1789,88 +1656,18 @@ export class ImageDecryptService { } /** - * 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined - * 已确认无 live 的路径会被记录,下次直接跳过 - */ - private checkLiveVideoCache(imagePath: string): string | undefined { - if (this.noLiveSet.has(imagePath)) return undefined - const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') - if (existsSync(livePath)) return this.filePathToUrl(livePath) - this.noLiveSet.add(imagePath) - return undefined - } - - /** - * 检测并分离 Motion Photo(实况照片) - * Google Motion Photo = JPEG + MP4 拼接在一起 - * 返回视频文件路径,如果不是实况照片则返回 null - */ - private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise { - // 只处理 JPEG 文件 - if (imageBuffer.length < 8) return null - if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null - - // 从末尾向前搜索 MP4 ftyp 原子签名 - // ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...] - // 实际起始位置在 ftyp 前4字节(大小字段) - const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp' - let videoOffset: number | null = null - - const searchEnd = Math.max(0, imageBuffer.length - 8) - for (let i = searchEnd; i > 0; i--) { - if (imageBuffer[i] === ftypSig[0] && - imageBuffer[i + 1] === ftypSig[1] && - imageBuffer[i + 2] === ftypSig[2] && - imageBuffer[i + 3] === ftypSig[3]) { - // ftyp 前4字节是 box size,实际 MP4 从这里开始 - videoOffset = i - 4 - break - } - } - - // 备用:从 XMP 元数据中读取偏移量 - if (videoOffset === null || videoOffset <= 0) { - try { - const text = imageBuffer.toString('latin1') - const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i) - if (match) { - const offset = parseInt(match[1], 10) - if (offset > 0 && offset < imageBuffer.length) { - videoOffset = imageBuffer.length - offset - } - } - } catch { } - } - - if (videoOffset === null || videoOffset <= 100) return null - - // 验证视频部分确实以有效 MP4 数据开头 - const videoStart = imageBuffer[videoOffset + 4] === 0x66 && - imageBuffer[videoOffset + 5] === 0x74 && - imageBuffer[videoOffset + 6] === 0x79 && - imageBuffer[videoOffset + 7] === 0x70 - if (!videoStart) return null - - // 写出视频文件 - const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') - const videoBuffer = imageBuffer.slice(videoOffset) - await writeFile(videoPath, videoBuffer) - return videoPath - } - - /** - * 解包 wxgf 格式 - * wxgf 是微信的图片格式,内部使用 HEVC 编码 + * 瑙e寘 wxgf 鏍煎紡 + * wxgf 鏄井淇$殑鍥剧墖鏍煎紡锛屽唴閮ㄤ娇鐢?HEVC 缂栫爜 */ private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> { - // 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf") + // 妫€鏌ユ槸鍚︽槸 wxgf 鏍煎紡 (77 78 67 66 = "wxgf") if (buffer.length < 20 || buffer[0] !== 0x77 || buffer[1] !== 0x78 || buffer[2] !== 0x67 || buffer[3] !== 0x66) { return { data: buffer, isWxgf: false } } - // 先尝试搜索内嵌的传统图片签名 + // 鍏堝皾璇曟悳绱㈠唴宓岀殑浼犵粺鍥剧墖绛惧悕 for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) { if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) { return { data: buffer.subarray(i), isWxgf: false } @@ -1881,27 +1678,27 @@ export class ImageDecryptService { } } - // 提取 HEVC NALU 裸流 + // 鎻愬彇 HEVC NALU 瑁告祦 const hevcData = this.extractHevcNalu(buffer) if (!hevcData || hevcData.length < 100) { return { data: buffer, isWxgf: true } } - // 尝试用 ffmpeg 转换 + // 灏濊瘯鐢?ffmpeg 杞崲 try { const jpgData = await this.convertHevcToJpg(hevcData) if (jpgData && jpgData.length > 0) { return { data: jpgData, isWxgf: false } } } catch { - // ffmpeg 转换失败 + // ffmpeg 杞崲澶辫触 } return { data: hevcData, isWxgf: true } } /** - * 从 wxgf 数据中提取 HEVC NALU 裸流 + * 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦 */ private extractHevcNalu(buffer: Buffer): Buffer | null { const nalUnits: Buffer[] = [] @@ -1947,26 +1744,25 @@ export class ImageDecryptService { } /** - * 获取 ffmpeg 可执行文件路径 - */ + * 鑾峰彇 ffmpeg 鍙墽琛屾枃浠惰矾寰? */ private getFfmpegPath(): string { const staticPath = getStaticFfmpegPath() - this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false }) + this.logInfo('ffmpeg 璺緞妫€娴?, { staticPath, exists: staticPath ? existsSync(staticPath) : false }) if (staticPath) { return staticPath } - // 回退到系统 ffmpeg + // 鍥為€€鍒扮郴缁?ffmpeg return 'ffmpeg' } /** - * 使用 ffmpeg 将 HEVC 裸流转换为 JPG + * 浣跨敤 ffmpeg 灏?HEVC 瑁告祦杞崲涓?JPG */ private convertHevcToJpg(hevcData: Buffer): Promise { const ffmpeg = this.getFfmpegPath() - this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) + this.logInfo('ffmpeg 杞崲寮€濮?, { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) return new Promise((resolve) => { const { spawn } = require('child_process') @@ -1992,17 +1788,17 @@ export class ImageDecryptService { proc.on('close', (code: number) => { if (code === 0 && chunks.length > 0) { - this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length }) + this.logInfo('ffmpeg 杞崲鎴愬姛', { outputSize: Buffer.concat(chunks).length }) resolve(Buffer.concat(chunks)) } else { const errMsg = Buffer.concat(errChunks).toString() - this.logInfo('ffmpeg 转换失败', { code, error: errMsg }) + this.logInfo('ffmpeg 杞崲澶辫触', { code, error: errMsg }) resolve(null) } }) proc.on('error', (err: Error) => { - this.logInfo('ffmpeg 进程错误', { error: err.message }) + this.logInfo('ffmpeg 杩涚▼閿欒', { error: err.message }) resolve(null) }) @@ -2011,7 +1807,7 @@ export class ImageDecryptService { }) } - // 保留原有的解密到文件方法(用于兼容) + // 淇濈暀鍘熸湁鐨勮В瀵嗗埌鏂囦欢鏂规硶锛堢敤浜庡吋瀹癸級 async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { const version = this.getDatVersion(inputPath) let decrypted: Buffer @@ -2023,7 +1819,7 @@ export class ImageDecryptService { decrypted = this.decryptDatV4(inputPath, xorKey, key) } else { if (!aesKey || aesKey.length !== 16) { - throw new Error('V4版本需要16字节AES密钥') + throw new Error('V4鐗堟湰闇€瑕?6瀛楄妭AES瀵嗛挜') } decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) } diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 089142a..ddbc0c5 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,13 +1,11 @@ -import { join, basename, extname, dirname } from 'path' -import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, writeFileSync } from 'fs' -import { app } from 'electron' +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { ConfigService } from './config' import Database from 'better-sqlite3' import { wcdbService } from './wcdbService' -import crypto from 'crypto' export interface VideoInfo { - videoUrl?: string // 视频文件路径 + videoUrl?: string // 视频文件路径(用于 readFile) coverUrl?: string // 封面 data URL thumbUrl?: string // 缩略图 data URL exists: boolean @@ -15,379 +13,276 @@ export interface VideoInfo { class VideoService { private configService: ConfigService - private resolvedCache = new Map() // md5 -> localPath constructor() { this.configService = new ConfigService() } - private logInfo(message: string, meta?: Record): void { - if (!this.configService.get('logEnabled')) return - const timestamp = new Date().toISOString() - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logLine = `[${timestamp}] [VideoService] ${message}${metaStr}\n` - this.writeLog(logLine) - } - - private logError(message: string, error?: unknown, meta?: Record): void { - if (!this.configService.get('logEnabled')) return - const timestamp = new Date().toISOString() - const errorStr = error ? ` Error: ${String(error)}` : '' - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logLine = `[${timestamp}] [VideoService] ERROR: ${message}${errorStr}${metaStr}\n` - console.error(`[VideoService] ${message}`, error, meta) - this.writeLog(logLine) - } - - private writeLog(line: string): void { - try { - const logDir = join(app.getPath('userData'), 'logs') - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }) - } - appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) - } catch (err) { - console.error('写入日志失败:', err) - } - } - + /** + * 获取数据库根目录 + */ private getDbPath(): string { return this.configService.get('dbPath') || '' } + /** + * 获取当前用户的wxid + */ private getMyWxid(): string { return this.configService.get('myWxid') || '' } - private getCacheBasePath(): string { - return this.configService.getCacheBasePath() - } - - private cleanWxid(wxid: string): string { - const trimmed = wxid.trim() - if (!trimmed) return trimmed - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - if (match) return match[1] - } - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - if (suffixMatch) return suffixMatch[1] - return trimmed - } - - private resolveAccountDir(dbPath: string, wxid: string): string | null { - if (!dbPath || !wxid) return null - const cleanedWxid = this.cleanWxid(wxid).toLowerCase() - const normalized = dbPath.replace(/[\\/]+$/, '') - const candidates: { path: string; mtime: number }[] = [] - - const checkDir = (p: string) => { - if (existsSync(p) && (existsSync(join(p, 'db_storage')) || existsSync(join(p, 'msg', 'video')) || existsSync(join(p, 'msg', 'attach')))) { - candidates.push({ path: p, mtime: this.getDirMtime(p) }) - } - } - - checkDir(join(normalized, wxid)) - checkDir(join(normalized, cleanedWxid)) - checkDir(normalized) - - try { - if (existsSync(normalized) && statSync(normalized).isDirectory()) { - const entries = readdirSync(normalized) - for (const entry of entries) { - const entryPath = join(normalized, entry) - try { - if (!statSync(entryPath).isDirectory()) continue - } catch { continue } - const lowerEntry = entry.toLowerCase() - if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) { - checkDir(entryPath) - } - } - } - } catch { } - - if (candidates.length === 0) return null - candidates.sort((a, b) => b.mtime - a.mtime) - return candidates[0].path - } - - private getDirMtime(dirPath: string): number { - try { - let mtime = statSync(dirPath).mtimeMs - const subs = ['db_storage', 'msg/video', 'msg/attach'] - for (const sub of subs) { - const p = join(dirPath, sub) - if (existsSync(p)) mtime = Math.max(mtime, statSync(p).mtimeMs) - } - return mtime - } catch { return 0 } - } - - private async ensureWcdbReady(): Promise { - if (wcdbService.isReady()) return true - const dbPath = this.configService.get('dbPath') - const decryptKey = this.configService.get('decryptKey') - const wxid = this.configService.get('myWxid') - if (!dbPath || !decryptKey || !wxid) return false - const cleanedWxid = this.cleanWxid(wxid) - return await wcdbService.open(dbPath, decryptKey, cleanedWxid) + /** + * 获取缓存目录(解密后的数据库存放位置) + */ + private getCachePath(): string { + return this.configService.get('cachePath') || '' } /** - * 计算会话哈希(对应磁盘目录名) + * 清理 wxid 目录名(去掉后缀) */ - private md5Hash(text: string): string { - return crypto.createHash('md5').update(text).digest('hex') - } + private cleanWxid(wxid: string): string { + const trimmed = wxid.trim() + if (!trimmed) return trimmed - private async resolveHardlinkPath(accountDir: string, md5: string): Promise { - const dbPath = join(accountDir, 'db_storage', 'hardlink', 'hardlink.db') - if (!existsSync(dbPath)) { - this.logInfo('hardlink.db 不存在', { dbPath }) - return null + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed } - try { - const ready = await this.ensureWcdbReady() - if (!ready) return null + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] - const tableResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'video_hardlink_info%' ORDER BY name DESC LIMIT 1") - - if (!tableResult.success || !tableResult.rows?.length) return null - const tableName = tableResult.rows[0].name + return trimmed + } - const escapedMd5 = md5.replace(/'/g, "''") - const rowResult = await wcdbService.execQuery('media', dbPath, - `SELECT dir1, dir2, file_name FROM ${tableName} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`) - - if (!rowResult.success || !rowResult.rows?.length) return null - - const row = rowResult.rows[0] - const dir1 = row.dir1 ?? row.DIR1 - const dir2 = row.dir2 ?? row.DIR2 - const file_name = row.file_name ?? row.fileName ?? row.FILE_NAME + /** + * 从 video_hardlink_info_v4 表查询视频文件名 + * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) + * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db + */ + private async queryVideoFileName(md5: string): Promise { + const cachePath = this.getCachePath() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) - if (dir1 === undefined || dir2 === undefined || !file_name) return null + console.log('[VideoService] queryVideoFileName called with MD5:', md5) + console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid) - const dirTableResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1") - if (!dirTableResult.success || !dirTableResult.rows?.length) return null - const dirTable = dirTableResult.rows[0].name + if (!wxid) return undefined - const getDirName = async (id: number) => { - const res = await wcdbService.execQuery('media', dbPath, `SELECT username FROM ${dirTable} WHERE rowid = ${id} LIMIT 1`) - return res.success && res.rows?.length ? String(res.rows[0].username) : null - } - - const dir1Name = await getDirName(Number(dir1)) - const dir2Name = await getDirName(Number(dir2)) - if (!dir1Name || !dir2Name) return null - - const candidates = [ - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Video', file_name), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, file_name), - join(accountDir, 'msg', 'video', dir2Name, file_name) + // 方法1:优先在 cachePath 下查找解密后的 hardlink.db + if (cachePath) { + const cacheDbPaths = [ + join(cachePath, cleanedWxid, 'hardlink.db'), + join(cachePath, wxid, 'hardlink.db'), + join(cachePath, 'hardlink.db'), + join(cachePath, 'databases', cleanedWxid, 'hardlink.db'), + join(cachePath, 'databases', wxid, 'hardlink.db') ] - for (const p of candidates) { + for (const p of cacheDbPaths) { if (existsSync(p)) { - this.logInfo('hardlink 命中', { path: p }) - return p - } - } - } catch (e) { - this.logError('resolveHardlinkPath 异常', e) - } - return null - } + console.log('[VideoService] Found decrypted hardlink.db at:', p) + try { + const db = new Database(p, { readonly: true }) + const row = db.prepare(` + SELECT file_name, md5 FROM video_hardlink_info_v4 + WHERE md5 = ? + LIMIT 1 + `).get(md5) as { file_name: string; md5: string } | undefined + db.close() - private async searchVideoFile(accountDir: string, md5: string, sessionId?: string): Promise { - const lowerMd5 = md5.toLowerCase() - - // 策略 1: 基于 sessionId 哈希的精准搜索 (XWeChat 核心逻辑) - if (sessionId) { - const sessHash = this.md5Hash(sessionId) - const attachRoot = join(accountDir, 'msg', 'attach', sessHash) - if (existsSync(attachRoot)) { - try { - const monthDirs = readdirSync(attachRoot).filter(d => /^\d{4}-\d{2}$/.test(d)) - for (const m of monthDirs) { - const videoDir = join(attachRoot, m, 'Video') - if (existsSync(videoDir)) { - // 尝试精确名和带数字后缀的名 - const files = readdirSync(videoDir) - const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4')) - if (match) return join(videoDir, match) + if (row?.file_name) { + const realMd5 = row.file_name.replace(/\.[^.]+$/, '') + console.log('[VideoService] Found video filename via cache:', realMd5) + return realMd5 } + } catch (e) { + console.log('[VideoService] Failed to query cached hardlink.db:', e) } - } catch { } + } } } - // 策略 2: 概率搜索所有 session 目录 (针对最近 3 个月) - const attachRoot = join(accountDir, 'msg', 'attach') - if (existsSync(attachRoot)) { - try { - const sessionDirs = readdirSync(attachRoot).filter(d => d.length === 32) - const now = new Date() - const months = [] - for (let i = 0; i < 3; i++) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1) - months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) - } + // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db + if (dbPath) { + const encryptedDbPaths = [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] - for (const sess of sessionDirs) { - for (const month of months) { - const videoDir = join(attachRoot, sess, month, 'Video') - if (existsSync(videoDir)) { - const files = readdirSync(videoDir) - const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4')) - if (match) return join(videoDir, match) + for (const p of encryptedDbPaths) { + if (existsSync(p)) { + console.log('[VideoService] Found encrypted hardlink.db at:', p) + try { + const escapedMd5 = md5.replace(/'/g, "''") + + // 用 md5 字段查询,获取 file_name + const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` + console.log('[VideoService] Query SQL:', sql) + + const result = await wcdbService.execQuery('media', p, sql) + console.log('[VideoService] Query result:', result) + + if (result.success && result.rows && result.rows.length > 0) { + const row = result.rows[0] + if (row?.file_name) { + // 提取不带扩展名的文件名作为实际视频 MD5 + const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') + console.log('[VideoService] Found video filename:', realMd5) + return realMd5 + } } + } catch (e) { + console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e) } } - } catch { } + } } - // 策略 3: 传统 msg/video 目录 - const videoRoot = join(accountDir, 'msg', 'video') - if (existsSync(videoRoot)) { - try { - const monthDirs = readdirSync(videoRoot).sort().reverse() - for (const m of monthDirs) { - const dirPath = join(videoRoot, m) - const files = readdirSync(dirPath) - const match = files.find(f => f.toLowerCase().startsWith(lowerMd5) && f.toLowerCase().endsWith('.mp4')) - if (match) return join(dirPath, match) - } - } catch { } - } - - return null - } - - private getXorKey(): number | undefined { - const raw = this.configService.get('imageXorKey') - if (typeof raw === 'number') return raw - if (typeof raw === 'string') { - const t = raw.trim() - return t.toLowerCase().startsWith('0x') ? parseInt(t, 16) : parseInt(t, 10) - } + console.log('[VideoService] No matching video found in hardlink.db') return undefined } - private isEncrypted(buffer: Buffer, xorKey: number, type: 'video' | 'image'): boolean { - if (buffer.length < 8) return false - const first = buffer[0] ^ xorKey - const second = buffer[1] ^ xorKey - - if (type === 'image') { - return (first === 0xFF && second === 0xD8) || (first === 0x89 && second === 0x50) || (first === 0x47 && second === 0x49) - } else { - // MP4 头部通常包含 'ftyp' - const f = buffer[4] ^ xorKey - const t = buffer[5] ^ xorKey - const y = buffer[6] ^ xorKey - const p = buffer[7] ^ xorKey - return (f === 0x66 && t === 0x74 && y === 0x79 && p === 0x70) || // 'ftyp' - (buffer[0] ^ xorKey) === 0x00 && (buffer[1] ^ xorKey) === 0x00 // 一些 mp4 以 00 00 开头 - } - } - - private filePathToUrl(filePath: string): string { - try { - const { pathToFileURL } = require('url') - const url = pathToFileURL(filePath).toString() - const s = statSync(filePath) - return `${url}?v=${Math.floor(s.mtimeMs)}` - } catch { - return `file:///${filePath.replace(/\\/g, '/')}` - } - } - - private handleFile(filePath: string, type: 'video' | 'image', sessionId?: string): string | undefined { - if (!existsSync(filePath)) return undefined - const xorKey = this.getXorKey() - + /** + * 将文件转换为 data URL + */ + private fileToDataUrl(filePath: string, mimeType: string): string | undefined { try { + if (!existsSync(filePath)) return undefined const buffer = readFileSync(filePath) - const isEnc = xorKey !== undefined && !Number.isNaN(xorKey) && this.isEncrypted(buffer, xorKey, type) - - if (isEnc) { - const decrypted = Buffer.alloc(buffer.length) - for (let i = 0; i < buffer.length; i++) decrypted[i] = buffer[i] ^ xorKey! - - if (type === 'image') { - return `data:image/jpeg;base64,${decrypted.toString('base64')}` - } else { - const cacheDir = join(this.getCacheBasePath(), 'Videos', this.cleanWxid(sessionId || 'unknown')) - if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }) - const outPath = join(cacheDir, `${basename(filePath)}`) - if (!existsSync(outPath) || statSync(outPath).size !== decrypted.length) { - writeFileSync(outPath, decrypted) - } - return this.filePathToUrl(outPath) - } - } - - if (type === 'image') { - return `data:image/jpeg;base64,${buffer.toString('base64')}` - } - return this.filePathToUrl(filePath) - } catch (e) { - this.logError(`处理${type}文件异常: ${filePath}`, e) - return type === 'image' ? undefined : this.filePathToUrl(filePath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } catch { + return undefined } } - async getVideoInfo(videoMd5: string, sessionId?: string): Promise { - this.logInfo('获取视频信息', { videoMd5, sessionId }) + /** + * 根据视频MD5获取视频文件信息 + * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ + * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg + */ + async getVideoInfo(videoMd5: string): Promise { + console.log('[VideoService] getVideoInfo called with MD5:', videoMd5) + const dbPath = this.getDbPath() const wxid = this.getMyWxid() - if (!dbPath || !wxid || !videoMd5) return { exists: false } - const accountDir = this.resolveAccountDir(dbPath, wxid) - if (!accountDir) { - this.logError('未找到账号目录', undefined, { dbPath, wxid }) + console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid) + + if (!dbPath || !wxid || !videoMd5) { + console.log('[VideoService] Missing required params') return { exists: false } } - // 1. 通过 hardlink 映射 - let videoPath = await this.resolveHardlinkPath(accountDir, videoMd5) - - // 2. 启发式搜索 - if (!videoPath) { - videoPath = await this.searchVideoFile(accountDir, videoMd5, sessionId) + // 先尝试从数据库查询真正的视频文件名 + const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 + console.log('[VideoService] Real video MD5:', realVideoMd5) + + const videoBaseDir = join(dbPath, wxid, 'msg', 'video') + console.log('[VideoService] Video base dir:', videoBaseDir) + + if (!existsSync(videoBaseDir)) { + console.log('[VideoService] Video base dir does not exist') + return { exists: false } } - if (videoPath && existsSync(videoPath)) { - this.logInfo('定位成功', { videoPath }) - const base = videoPath.slice(0, -4) - const coverPath = `${base}.jpg` - const thumbPath = `${base}_thumb.jpg` + // 遍历年月目录查找视频文件 + try { + const allDirs = readdirSync(videoBaseDir) + console.log('[VideoService] Found year-month dirs:', allDirs) - return { - videoUrl: this.handleFile(videoPath, 'video', sessionId), - coverUrl: this.handleFile(coverPath, 'image', sessionId), - thumbUrl: this.handleFile(thumbPath, 'image', sessionId), - exists: true + // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 + const yearMonthDirs = allDirs + .filter(dir => { + const dirPath = join(videoBaseDir, dir) + return statSync(dirPath).isDirectory() + }) + .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + + const videoPath = join(dirPath, `${realVideoMd5}.mp4`) + const coverPath = join(dirPath, `${realVideoMd5}.jpg`) + const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) + + console.log('[VideoService] Checking:', videoPath) + + // 检查视频文件是否存在 + if (existsSync(videoPath)) { + console.log('[VideoService] Video file found!') + return { + videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } } + + console.log('[VideoService] Video file not found in any directory') + } catch (e) { + console.error('[VideoService] Error searching for video:', e) } - this.logInfo('定位失败', { videoMd5 }) return { exists: false } } + /** + * 根据消息内容解析视频MD5 + */ parseVideoMd5(content: string): string | undefined { + console.log('[VideoService] parseVideoMd5 called, content length:', content?.length) + + // 打印前500字符看看 XML 结构 + console.log('[VideoService] XML preview:', content?.substring(0, 500)) + if (!content) return undefined + try { - const m = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || - /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || - /([a-fA-F0-9]+)<\/md5>/i.exec(content) - return m ? m[1].toLowerCase() : undefined - } catch { return undefined } + // 提取所有可能的 md5 值进行日志 + const allMd5s: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi + let match + while ((match = md5Regex.exec(content)) !== null) { + allMd5s.push(`${match[0]}`) + } + console.log('[VideoService] All MD5 attributes found:', allMd5s) + + // 提取 md5(用于查询 hardlink.db) + // 注意:不是 rawmd5,rawmd5 是另一个值 + // 格式: md5="xxx" 或 xxx + + // 尝试从videomsg标签中提取md5 + const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMatch) { + console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1]) + return videoMsgMatch[1].toLowerCase() + } + + const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + console.log('[VideoService] Found MD5 via attribute:', attrMatch[1]) + return attrMatch[1].toLowerCase() + } + + const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5Match) { + console.log('[VideoService] Found MD5 via tag:', md5Match[1]) + return md5Match[1].toLowerCase() + } + + console.log('[VideoService] No MD5 found in content') + } catch (e) { + console.error('[VideoService] 解析视频MD5失败:', e) + } + + return undefined } } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index af71340..ee92354 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3540,13 +3540,12 @@ function MessageBubble({ const requestVideoInfo = useCallback(async () => { if (!videoMd5 || videoLoadingRef.current) return - videoLoadingRef.current = true - setVideoLoading(true) - try { - const result = await window.electronAPI.video.getVideoInfo(videoMd5, session.username) - if (result && result.success && result.exists) { - setVideoInfo({ - exists: result.exists, + videoLoadingRef.current = true + setVideoLoading(true) + try { + const result = await window.electronAPI.video.getVideoInfo(videoMd5) + if (result && result.success && result.exists) { + setVideoInfo({ exists: result.exists, videoUrl: result.videoUrl, coverUrl: result.coverUrl, thumbUrl: result.thumbUrl @@ -3560,7 +3559,7 @@ function MessageBubble({ videoLoadingRef.current = false setVideoLoading(false) } - }, [videoMd5, session.username]) + }, [videoMd5]) // 视频进入视野时自动加载 useEffect(() => { From c359821844e5b19056c3b084e38de47e6a74d2dd Mon Sep 17 00:00:00 2001 From: ace Date: Sat, 28 Feb 2026 18:22:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(electron):=20=E4=BF=AE=E5=A4=8D=20image?= =?UTF-8?q?DecryptService=20=E4=B8=AD=E7=9A=84=E4=B8=AD=E6=96=87=E4=B9=B1?= =?UTF-8?q?=E7=A0=81=E3=80=81=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF=E5=92=8C?= =?UTF-8?q?=20TypeScript=20=E6=A3=80=E6=9F=A5=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/imageDecryptService.ts | 403 ++++++++++++----------- 1 file changed, 213 insertions(+), 190 deletions(-) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 6592030..62c683a 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -8,9 +8,10 @@ import { Worker } from 'worker_threads' import { ConfigService } from './config' import { wcdbService } from './wcdbService' -// 鑾峰彇 ffmpeg-static 鐨勮矾寰?function getStaticFfmpegPath(): string | null { +// 获取 ffmpeg-static 的路径 +function getStaticFfmpegPath(): string | null { try { - // 鏂规硶1: 鐩存帴 require ffmpeg-static + // 方法1: 直接 require ffmpeg-static // eslint-disable-next-line @typescript-eslint/no-var-requires const ffmpegStatic = require('ffmpeg-static') @@ -18,13 +19,13 @@ import { wcdbService } from './wcdbService' return ffmpegStatic } - // 鏂规硶2: 鎵嬪姩鏋勫缓璺緞锛堝紑鍙戠幆澧冿級 + // 方法2: 手动构建路径(开发环境) const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') if (existsSync(devPath)) { return devPath } - // 鏂规硶3: 鎵撳寘鍚庣殑璺緞 + // 方法3: 打包后的路径 if (app.isPackaged) { const resourcesPath = process.resourcesPath const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') @@ -43,7 +44,8 @@ type DecryptResult = { success: boolean localPath?: string error?: string - isThumb?: boolean // 鏄惁鏄缉鐣ュ浘锛堟病鏈夐珮娓呭浘鏃惰繑鍥炵缉鐣ュ浘锛?} + isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) +} type HardlinkState = { imageTable?: string @@ -66,7 +68,8 @@ export class ImageDecryptService { const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` - // 鍙啓鍏ユ枃浠讹紝涓嶈緭鍑哄埌鎺у埗鍙? this.writeLog(logLine) + // 只写入文件,不输出到控制台 + this.writeLog(logLine) } private logError(message: string, error?: unknown, meta?: Record): void { @@ -76,10 +79,10 @@ export class ImageDecryptService { const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` - // 鍚屾椂杈撳嚭鍒版帶鍒跺彴 + // 同时输出到控制台 console.error(message, error, meta) - // 鍐欏叆鏃ュ織鏂囦欢 + // 写入日志文件 this.writeLog(logLine) } @@ -91,7 +94,7 @@ export class ImageDecryptService { } appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) } catch (err) { - console.error('鍐欏叆鏃ュ織澶辫触:', err) + console.error('写入日志失败:', err) } } @@ -100,7 +103,7 @@ export class ImageDecryptService { const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { - return { success: false, error: '缂哄皯鍥剧墖鏍囪瘑' } + return { success: false, error: '缺少图片标识' } } for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) @@ -137,15 +140,15 @@ export class ImageDecryptService { return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } } } - this.logInfo('鏈壘鍒扮紦瀛?, { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '鏈壘鍒扮紦瀛樺浘鐗? } + this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) + return { success: false, error: '未找到缓存图片' } } async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { await this.ensureCacheIndexed() const cacheKey = payload.imageMd5 || payload.imageDatName if (!cacheKey) { - return { success: false, error: '缂哄皯鍥剧墖鏍囪瘑' } + return { success: false, error: '缺少图片标识' } } if (!payload.force) { @@ -177,19 +180,19 @@ export class ImageDecryptService { payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, cacheKey: string ): Promise { - this.logInfo('寮€濮嬭В瀵嗗浘鐗?, { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) + this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) { - this.logError('閰嶇疆缂哄け', undefined, { wxid: !!wxid, dbPath: !!dbPath }) - return { success: false, error: '鏈厤缃处鍙锋垨鏁版嵁搴撹矾寰? } + this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) + return { success: false, error: '未配置账号或数据库路径' } } const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) { - this.logError('鏈壘鍒拌处鍙风洰褰?, undefined, { dbPath, wxid }) - return { success: false, error: '鏈壘鍒拌处鍙风洰褰? } + this.logError('未找到账号目录', undefined, { dbPath, wxid }) + return { success: false, error: '未找到账号目录' } } const datPath = await this.resolveDatPath( @@ -200,17 +203,17 @@ export class ImageDecryptService { { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } ) - // 濡傛灉瑕佹眰楂樻竻鍥句絾娌℃壘鍒帮紝鐩存帴杩斿洖鎻愮ず + // 如果要求高清图但没找到,直接返回提示 if (!datPath && payload.force) { - this.logError('鏈壘鍒伴珮娓呭浘', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '鏈壘鍒伴珮娓呭浘锛岃鍦ㄥ井淇′腑鐐瑰紑璇ュ浘鐗囨煡鐪嬪悗閲嶈瘯' } + this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) + return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } } if (!datPath) { - this.logError('鏈壘鍒癉AT鏂囦欢', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '鏈壘鍒板浘鐗囨枃浠? } + this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) + return { success: false, error: '未找到图片文件' } } - this.logInfo('鎵惧埌DAT鏂囦欢', { datPath }) + this.logInfo('找到DAT文件', { datPath }) if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) @@ -221,12 +224,12 @@ export class ImageDecryptService { return { success: true, localPath, isThumb } } - // 鏌ユ壘宸茬紦瀛樼殑瑙e瘑鏂囦欢 + // 查找已缓存的解密文件 const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) if (existing) { - this.logInfo('鎵惧埌宸茶В瀵嗘枃浠?, { existing, isHd: this.isHdPath(existing) }) + this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) const isHd = this.isHdPath(existing) - // 濡傛灉瑕佹眰楂樻竻浣嗘壘鍒扮殑鏄缉鐣ュ浘锛岀户缁В瀵嗛珮娓呭浘 + // 如果要求高清但找到的是缩略图,继续解密高清图 if (!(payload.force && !isHd)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) const dataUrl = this.fileToDataUrl(existing) @@ -238,7 +241,8 @@ export class ImageDecryptService { } const xorKeyRaw = this.configService.get('imageXorKey') as unknown - // 鏀寔鍗佸叚杩涘埗鏍煎紡锛堝 0x53锛夊拰鍗佽繘鍒舵牸寮? let xorKey: number + // 支持十六进制格式(如 0x53)和十进制格式 + let xorKey: number if (typeof xorKeyRaw === 'number') { xorKey = xorKeyRaw } else { @@ -250,21 +254,23 @@ export class ImageDecryptService { } } if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { - return { success: false, error: '鏈厤缃浘鐗囪В瀵嗗瘑閽? } + return { success: false, error: '未配置图片解密密钥' } } const aesKeyRaw = this.configService.get('imageAesKey') const aesKey = this.resolveAesKey(aesKeyRaw) - this.logInfo('寮€濮嬭В瀵咲AT鏂囦欢', { datPath, xorKey, hasAesKey: !!aesKey }) + this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) - // 妫€鏌ユ槸鍚︽槸 wxgf 鏍煎紡锛屽鏋滄槸鍒欏皾璇曟彁鍙栫湡瀹炲浘鐗囨暟鎹? const wxgfResult = await this.unwrapWxgf(decrypted) + // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 + const wxgfResult = await this.unwrapWxgf(decrypted) decrypted = wxgfResult.data let ext = this.detectImageExtension(decrypted) - // 濡傛灉鏄?wxgf 鏍煎紡涓旀病妫€娴嬪埌鎵╁睍鍚? if (wxgfResult.isWxgf && !ext) { + // 如果是 wxgf 格式且没检测到扩展名 + if (wxgfResult.isWxgf && !ext) { ext = '.hevc' } @@ -272,15 +278,16 @@ export class ImageDecryptService { const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) await writeFile(outputPath, decrypted) - this.logInfo('瑙e瘑鎴愬姛', { outputPath, size: decrypted.length }) + this.logInfo('解密成功', { outputPath, size: decrypted.length }) - // 瀵逛簬 hevc 鏍煎紡锛岃繑鍥為敊璇彁绀? if (finalExt === '.hevc') { + if (finalExt === '.hevc') { return { success: false, - error: '姝ゅ浘鐗囦负寰俊鏂版牸寮?wxgf)锛岄渶瑕佸畨瑁?ffmpeg 鎵嶈兘鏄剧ず', + error: '此图片为微信新格式 (wxgf),需要安装 ffmpeg 才能显示', isThumb: this.isThumbnailPath(datPath) } } + const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { @@ -291,7 +298,7 @@ export class ImageDecryptService { this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb } } catch (e) { - this.logError('瑙e瘑澶辫触', e, { md5: payload.imageMd5, datName: payload.imageDatName }) + this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } } } @@ -322,7 +329,8 @@ export class ImageDecryptService { } /** - * 鑾峰彇瑙e瘑鍚庣殑缂撳瓨鐩綍锛堢敤浜庢煡鎵?hardlink.db锛? */ + * 获取解密后的缓存目录(用于查找 hardlink.db) + */ private getDecryptedCacheDir(wxid: string): string | null { const cachePath = this.configService.get('cachePath') if (!cachePath) return null @@ -330,7 +338,7 @@ export class ImageDecryptService { const cleanedWxid = this.cleanAccountDirName(wxid) const cacheAccountDir = join(cachePath, cleanedWxid) - // 妫€鏌ョ紦瀛樼洰褰曚笅鏄惁鏈?hardlink.db + // 检查缓存目录下是否有 hardlink.db if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { return cacheAccountDir } @@ -373,7 +381,7 @@ export class ImageDecryptService { const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -387,15 +395,36 @@ export class ImageDecryptService { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false this.logInfo('[ImageDecrypt] resolveDatPath', { - accountDir, imageMd5, imageDatName, - sessionId, allowThumbnail, skipResolvedCache }) - // 浼樺厛閫氳繃 hardlink.db 鏌ヨ + if (!skipResolvedCache) { + if (imageMd5) { + const cached = this.resolvedCache.get(imageMd5) + if (cached && existsSync(cached)) return cached + } + if (imageDatName) { + const cached = this.resolvedCache.get(imageDatName) + if (cached && existsSync(cached)) return cached + } + } + + // 1. 通过 MD5 快速定位 (MsgAttach 目录) + if (imageMd5) { + const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail) + if (res) return res + } + + // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 + if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail) + if (res) return res + } + + // 优先通过 hardlink.db 查询 if (imageMd5) { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) @@ -407,15 +436,16 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 鎵惧埌鐨勬槸缂╃暐鍥撅紝浣嗚姹傞珮娓呭浘 - // 灏濊瘯鍦ㄥ悓涓€鐩綍涓嬫煡鎵鹃珮娓呭浘鍙樹綋锛堝揩閫熸煡鎵撅紝涓嶉亶鍘嗭級 + // hardlink 找到的是缩略图,但要求高清图 + // 尝试在同一目录下查找高清图变体(快速查找,不遍历) const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - // 娌℃壘鍒伴珮娓呭浘锛岃繑鍥?null锛堜笉杩涜鍏ㄥ眬鎼滅储锛? return null + // 没找到高清图,返回 null(不进行全局搜索) + return null } this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { @@ -428,7 +458,7 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, fallbackPath) return fallbackPath } - // 鎵惧埌缂╃暐鍥句絾瑕佹眰楂樻竻鍥撅紝灏濊瘯鍚岀洰褰曟煡鎵鹃珮娓呭浘鍙樹綋 + // 找到缩略图但要求高清图,尝试同目录查找高清图变体 const hdPath = this.findHdVariantInSameDir(fallbackPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) @@ -450,7 +480,7 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 鎵惧埌鐨勬槸缂╃暐鍥撅紝浣嗚姹傞珮娓呭浘 + // hardlink 找到的是缩略图,但要求高清图 const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) @@ -461,7 +491,7 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // 濡傛灉瑕佹眰楂樻竻鍥句絾 hardlink 娌℃壘鍒帮紝涔熶笉瑕佹悳绱簡锛堟悳绱㈠お鎱級 + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) if (!allowThumbnail) { return null } @@ -471,7 +501,7 @@ export class ImageDecryptService { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { if (allowThumbnail || !this.isThumbnailPath(cached)) return cached - // 缂撳瓨鐨勬槸缂╃暐鍥撅紝灏濊瘯鎵鹃珮娓呭浘 + // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath } @@ -574,9 +604,7 @@ export class ImageDecryptService { }).catch(() => { }) } - private looksLikeMd5(value: string): boolean { - return /^[a-fA-F0-9]{16,32}$/.test(value) - } + private resolveHardlinkDbPath(accountDir: string): string | null { const wxid = this.configService.get('myWxid') @@ -643,12 +671,14 @@ export class ImageDecryptService { } } - // dir1 鍜?dir2 鏄?rowid锛岄渶瑕佷粠 dir2id 琛ㄦ煡璇㈠搴旂殑鐩綍鍚? let dir1Name: string | null = null + // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 + let dir1Name: string | null = null let dir2Name: string | null = null if (state.dirTable) { try { - // 閫氳繃 rowid 鏌ヨ鐩綍鍚? const dir1Result = await wcdbService.execQuery( + // 通过 rowid 查询目录名 + const dir1Result = await wcdbService.execQuery( 'media', hardlinkPath, `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` @@ -677,7 +707,7 @@ export class ImageDecryptService { return null } - // 鏋勫缓璺緞: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} + // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} const possiblePaths = [ join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), @@ -767,15 +797,16 @@ export class ImageDecryptService { const root = join(accountDir, 'msg', 'attach') if (!existsSync(root)) return null - // 浼樺寲1锛氬揩閫熸鐜囨€ф煡鎵? // 鍖呭惈锛?. 鍩轰簬鏂囦欢鍚嶇殑鍓嶇紑鐚滄祴 (鏃х増) - // 2. 鍩轰簬鏃ユ湡鐨勬渶杩戞湀浠芥壂鎻?(鏂扮増鏃犵储寮曟椂) + // 优化1:快速概率性查找 + // 包含:1. 基于文件名的前缀猜测 (旧版) + // 2. 基于日期的最近月份扫描 (新版无索引时) const fastHit = await this.fastProbabilisticSearch(root, datName) if (fastHit) { this.resolvedCache.set(key, fastHit) return fastHit } - // 浼樺寲2锛氬厹搴曟壂鎻?(寮傛闈為樆濉? + // 优化2:兜底扫描 (异步非阻塞) const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) if (found) { this.resolvedCache.set(key, found) @@ -785,15 +816,16 @@ export class ImageDecryptService { } /** - * 鍩轰簬鏂囦欢鍚嶇殑鍝堝笇鐗瑰緛鐚滄祴鍙兘鐨勮矾寰? * 鍖呭惈锛?. 寰俊鏃х増缁撴瀯 filename.substr(0, 2)/... - * 2. 寰俊鏂扮増缁撴瀯 msg/attach/{hash}/{YYYY-MM}/Img/filename + * 基于文件名的哈希特征猜测可能的路径 + * 包含:1. 微信旧版结构 filename.substr(0, 2)/... + * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ - private async fastProbabilisticSearch(root: string, datName: string): Promise { + private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise { const { promises: fs } = require('fs') const { join } = require('path') try { - // --- 绛栫暐 A: 鏃х増璺緞鐚滄祴 (msg/attach/xx/yy/...) --- + // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- const lowerName = datName.toLowerCase() let baseName = lowerName if (baseName.endsWith('.dat')) { @@ -876,15 +908,16 @@ export class ImageDecryptService { } /** - * 鍦ㄥ悓涓€鐩綍涓嬫煡鎵鹃珮娓呭浘鍙樹綋 - * 缂╃暐鍥? xxx_t.dat -> 楂樻竻鍥? xxx_h.dat 鎴?xxx.dat + * 在同一目录下查找高清图变体 + * 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat */ private findHdVariantInSameDir(thumbPath: string): string | null { try { const dir = dirname(thumbPath) const fileName = basename(thumbPath).toLowerCase() - // 鎻愬彇鍩虹鍚嶇О锛堝幓鎺?_t.dat 鎴?.t.dat锛? let baseName = fileName + // 提取基础名称(去掉 _t.dat 或 .t.dat) + let baseName = fileName if (baseName.endsWith('_t.dat')) { baseName = baseName.slice(0, -6) } else if (baseName.endsWith('.t.dat')) { @@ -893,7 +926,8 @@ export class ImageDecryptService { return null } - // 灏濊瘯鏌ユ壘楂樻竻鍥惧彉浣? const variants = [ + // 尝试查找高清图变体 + const variants = [ `${baseName}_h.dat`, `${baseName}.h.dat`, `${baseName}.dat` @@ -957,55 +991,6 @@ export class ImageDecryptService { }) } - private matchesDatName(fileName: string, datName: string): boolean { - const lower = fileName.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - const normalizedBase = this.normalizeDatBase(base) - const normalizedTarget = this.normalizeDatBase(datName.toLowerCase()) - if (normalizedBase === normalizedTarget) return true - const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i') - if (pattern.test(lower)) return true - return lower.endsWith('.dat') && lower.includes(datName) - } - - private scoreDatName(fileName: string): number { - if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 - if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 - return 2 - } - - private isThumbnailDat(fileName: string): boolean { - return fileName.includes('.t.dat') || fileName.includes('_t.dat') - } - - private hasXVariant(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) - } - - private isThumbnailPath(filePath: string): boolean { - const lower = basename(filePath).toLowerCase() - if (this.isThumbnailDat(lower)) return true - const ext = extname(lower) - const base = ext ? lower.slice(0, -ext.length) : lower - // 鏀寔鏂板懡鍚?_thumb 鍜屾棫鍛藉悕 _t - return base.endsWith('_t') || base.endsWith('_thumb') - } - - private isHdPath(filePath: string): boolean { - const lower = basename(filePath).toLowerCase() - const ext = extname(lower) - const base = ext ? lower.slice(0, -ext.length) : lower - return base.endsWith('_hd') || base.endsWith('_h') - } - - private hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) - } - - private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) - } - private normalizeDatBase(name: string): string { let base = name.toLowerCase() if (base.endsWith('.dat') || base.endsWith('.jpg')) { @@ -1017,34 +1002,24 @@ export class ImageDecryptService { return base } - private sanitizeDirName(name: string): string { - const trimmed = name.trim() - if (!trimmed) return 'unknown' - return trimmed.replace(/[<>:"/\\|?*]/g, '_') + private hasImageVariantSuffix(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) } - private resolveTimeDir(datPath: string): string { - const parts = datPath.split(/[\\/]+/) - for (const part of parts) { - if (/^\d{4}-\d{2}$/.test(part)) return part - } - try { - const stat = statSync(datPath) - const year = stat.mtime.getFullYear() - const month = String(stat.mtime.getMonth() + 1).padStart(2, '0') - return `${year}-${month}` - } catch { - return 'unknown-time' - } + private isLikelyImageDatBase(baseLower: string): boolean { + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) } + + private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { const allRoots = this.getAllCacheRoots() const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] - // 閬嶅巻鎵€鏈夊彲鑳界殑缂撳瓨鏍硅矾寰? for (const root of allRoots) { - // 绛栫暐1: 鏂扮洰褰曠粨鏋?Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg + // 遍历所有可能的缓存根路径 + for (const root of allRoots) { + // 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg if (sessionId) { const sessionDir = join(root, this.sanitizeDirName(sessionId)) if (existsSync(sessionDir)) { @@ -1053,7 +1028,7 @@ export class ImageDecryptService { .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) .map(d => d.name) .sort() - .reverse() // 鏈€鏂扮殑鏃ユ湡浼樺厛 + .reverse() // 最新的日期优先 for (const dateDir of dateDirs) { const imageDir = join(sessionDir, dateDir) @@ -1064,14 +1039,15 @@ export class ImageDecryptService { } } - // 绛栫暐2: 閬嶅巻鎵€鏈?sessionId 鐩綍鏌ユ壘锛堝鏋滄病鏈夋寚瀹?sessionId锛? try { + // 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId) + try { const sessionDirs = readdirSync(root, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name) for (const session of sessionDirs) { const sessionDir = join(root, session) - // 妫€鏌ユ槸鍚︽槸鏃ユ湡鐩綍缁撴瀯 + // 检查是否是日期目录结构 try { const subDirs = readdirSync(sessionDir, { withFileTypes: true }) .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) @@ -1086,14 +1062,14 @@ export class ImageDecryptService { } } catch { } - // 绛栫暐3: 鏃х洰褰曠粨鏋?Images/{normalizedKey}/{normalizedKey}_thumb.jpg + // 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg const oldImageDir = join(root, normalizedKey) if (existsSync(oldImageDir)) { const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd) if (hit) return hit } - // 绛栫暐4: 鏈€鏃х殑骞抽摵缁撴瀯 Images/{file}.jpg + // 策略4: 最旧的平铺结构 Images/{file}.jpg for (const ext of extensions) { const candidate = join(root, `${cacheKey}${ext}`) if (existsSync(candidate)) return candidate @@ -1113,7 +1089,8 @@ export class ImageDecryptService { extensions: string[], preferHd: boolean ): string | null { - // 鍏堟鏌ュ苟鍒犻櫎鏃х殑 .hevc 鏂囦欢锛坒fmpeg 杞崲澶辫触鏃堕仐鐣欑殑锛? const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`) + // 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的) + const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`) const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`) try { if (existsSync(hevcThumb)) { @@ -1132,7 +1109,8 @@ export class ImageDecryptService { const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) if (existsSync(thumbPath)) return thumbPath - // 鍏佽杩斿洖 _hd 鏍煎紡锛堝洜涓哄畠鏈?_hd 鍙樹綋鍚庣紑锛? if (!preferHd) { + // 允许返回 _hd 格式(因为它有 _hd 变体后缀) + if (!preferHd) { const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) if (existsSync(hdPath)) return hdPath } @@ -1145,9 +1123,11 @@ export class ImageDecryptService { const lower = name.toLowerCase() const base = lower.endsWith('.dat') ? name.slice(0, -4) : name - // 鎻愬彇鍩虹鍚嶇О锛堝幓鎺?_t, _h 绛夊悗缂€锛? const normalizedBase = this.normalizeDatBase(base) + // 提取基础名称(去掉 _t, _h 等后缀) + const normalizedBase = this.normalizeDatBase(base) - // 鍒ゆ柇鏄缉鐣ュ浘杩樻槸楂樻竻鍥? const isThumb = this.isThumbnailDat(lower) + // 判断是缩略图还是高清图 + const isThumb = this.isThumbnailDat(lower) const suffix = isThumb ? '_thumb' : '_hd' const contactDir = this.sanitizeDirName(sessionId || 'unknown') @@ -1228,7 +1208,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) continue if (this.isThumbnailDat(lower)) continue const baseLower = lower.slice(0, -4) - // 鍙帓闄ゆ病鏈?_x 鍙樹綋鍚庣紑鐨勬枃浠讹紙鍏佽 _hd銆乢h 绛夋墍鏈夊甫鍙樹綋鐨勶級 + // 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的) if (!this.hasXVariant(baseLower)) continue if (this.normalizeDatBase(baseLower) !== target) continue return join(dirPath, entry) @@ -1241,7 +1221,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) - // 鍙鏌ユ槸鍚︽湁 _x 鍙樹綋鍚庣紑锛堝厑璁?_hd銆乢h 绛夋墍鏈夊甫鍙樹綋鐨勶級 + // 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的) return this.hasXVariant(baseLower) } @@ -1266,51 +1246,53 @@ export class ImageDecryptService { private async ensureCacheIndexed(): Promise { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing - this.cacheIndexing = new Promise((resolve) => { - // 鎵弿鎵€鏈夊彲鑳界殑缂撳瓨鏍圭洰褰? const allRoots = this.getAllCacheRoots() - this.logInfo('寮€濮嬬储寮曠紦瀛?, { roots: allRoots.length }) + this.cacheIndexing = (async () => { + // 扫描所有可能的缓存根目录 + const allRoots = this.getAllCacheRoots() + this.logInfo('开始索引缓存', { roots: allRoots.length }) for (const root of allRoots) { try { - this.indexCacheDir(root, 3, 0) // 澧炲姞娣卞害鍒?锛屾敮鎸?sessionId/YYYY-MM 缁撴瀯 + this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构 } catch (e) { - this.logError('绱㈠紩鐩綍澶辫触', e, { root }) + this.logError('索引目录失败', e, { root }) } } - this.logInfo('缂撳瓨绱㈠紩瀹屾垚', { entries: this.resolvedCache.size }) + this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) this.cacheIndexed = true this.cacheIndexing = null - resolve() - }) + })() return this.cacheIndexing } /** - * 鑾峰彇鎵€鏈夊彲鑳界殑缂撳瓨鏍硅矾寰勶紙鐢ㄤ簬鏌ユ壘宸茬紦瀛樼殑鍥剧墖锛? * 鍖呭惈褰撳墠璺緞銆侀厤缃矾寰勩€佹棫鐗堟湰璺緞 + * 获取所有可能的缓存根路径(用于查找已缓存的图片) + * 包含当前路径、配置路径、旧版本路径 */ private getAllCacheRoots(): string[] { const roots: string[] = [] const configured = this.configService.get('cachePath') const documentsPath = app.getPath('documents') - // 涓昏璺緞锛堝綋鍓嶄娇鐢ㄧ殑锛? const mainRoot = this.getCacheRoot() + // 主要路径(当前使用的) + const mainRoot = this.getCacheRoot() roots.push(mainRoot) - // 濡傛灉閰嶇疆浜嗚嚜瀹氫箟璺緞锛屼篃妫€鏌ュ叾涓嬬殑 Images + // 如果配置了自定义路径,也检查其下的 Images if (configured) { roots.push(join(configured, 'Images')) roots.push(join(configured, 'images')) } - // 榛樿璺緞 + // 默认路径 roots.push(join(documentsPath, 'WeFlow', 'Images')) roots.push(join(documentsPath, 'WeFlow', 'images')) - // 鍏煎鏃ц矾寰勶紙濡傛灉鏈夌殑璇濓級 + // 兼容旧路径(如果有的话) roots.push(join(documentsPath, 'WeFlowData', 'Images')) - // 鍘婚噸骞惰繃婊ゅ瓨鍦ㄧ殑璺緞 + // 去重并过滤存在的路径 const uniqueRoots = Array.from(new Set(roots)) const existingRoots = uniqueRoots.filter(r => existsSync(r)) @@ -1392,14 +1374,14 @@ export class ImageDecryptService { } // version === 2 if (!aesKey || aesKey.length !== 16) { - throw new Error('璇峰埌璁剧疆閰嶇疆鍥剧墖瑙e瘑瀵嗛挜') + throw new Error('请到设置配置图片解密密钥') } return this.decryptDatV4(datPath, xorKey, aesKey) } private getDatVersion(inputPath: string): number { if (!existsSync(inputPath)) { - throw new Error('鏂囦欢涓嶅瓨鍦?) + throw new Error('文件不存在') } const bytes = readFileSync(inputPath) if (bytes.length < 6) { @@ -1427,7 +1409,7 @@ export class ImageDecryptService { private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer { const bytes = readFileSync(inputPath) if (bytes.length < 0x0f) { - throw new Error('鏂囦欢澶皬锛屾棤娉曡В鏋?) + throw new Error('文件太小,无法解析') } const header = bytes.subarray(0, 0x0f) @@ -1435,11 +1417,13 @@ export class ImageDecryptService { const aesSize = this.bytesToInt32(header.subarray(6, 10)) const xorSize = this.bytesToInt32(header.subarray(10, 14)) - // AES 鏁版嵁闇€瑕佸榻愬埌 16 瀛楄妭锛圥KCS7 濉厖锛? // 褰?aesSize % 16 === 0 鏃讹紝浠嶉渶瑕侀澶?16 瀛楄妭鐨勫~鍏? const remainder = ((aesSize % 16) + 16) % 16 + // AES 数据需要对齐到 16 字节(PKCS7 填充) + // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 + const remainder = ((aesSize % 16) + 16) % 16 const alignedAesSize = aesSize + (16 - remainder) if (alignedAesSize > data.length) { - throw new Error('鏂囦欢鏍煎紡寮傚父锛欰ES 鏁版嵁闀垮害瓒呰繃鏂囦欢瀹為檯闀垮害') + throw new Error('文件格式异常:AES 数据长度超过文件实际长度') } const aesData = data.subarray(0, alignedAesSize) @@ -1449,13 +1433,13 @@ export class ImageDecryptService { decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) - // 浣跨敤 PKCS7 濉厖绉婚櫎 + // 使用 PKCS7 填充移除 unpadded = this.strictRemovePadding(decrypted) } const remaining = data.subarray(alignedAesSize) if (xorSize < 0 || xorSize > remaining.length) { - throw new Error('鏂囦欢鏍煎紡寮傚父锛歑OR 鏁版嵁闀垮害涓嶅悎娉?) + throw new Error('文件格式异常:XOR 数据长度不合法') } let rawData = Buffer.alloc(0) @@ -1463,7 +1447,7 @@ export class ImageDecryptService { if (xorSize > 0) { const rawLength = remaining.length - xorSize if (rawLength < 0) { - throw new Error('鏂囦欢鏍煎紡寮傚父锛氬師濮嬫暟鎹暱搴﹀皬浜嶺OR闀垮害') + throw new Error('文件格式异常:原始数据长度小于XOR长度') } rawData = remaining.subarray(0, rawLength) const xorData = remaining.subarray(rawLength) @@ -1481,29 +1465,29 @@ export class ImageDecryptService { private bytesToInt32(bytes: Buffer): number { if (bytes.length !== 4) { - throw new Error('闇€瑕?涓瓧鑺?) + throw new Error('需要 4 个字节') } return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) } asciiKey16(keyString: string): Buffer { if (keyString.length < 16) { - throw new Error('AES瀵嗛挜鑷冲皯闇€瑕?6涓瓧绗?) + throw new Error('AES密钥至少需要 16 个字符') } return Buffer.from(keyString, 'ascii').subarray(0, 16) } private strictRemovePadding(data: Buffer): Buffer { if (!data.length) { - throw new Error('瑙e瘑缁撴灉涓虹┖锛屽~鍏呴潪娉?) + throw new Error('解密结果为空,填充非法') } const paddingLength = data[data.length - 1] if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) { - throw new Error('PKCS7 濉厖闀垮害闈炴硶') + throw new Error('PKCS7 填充长度非法') } for (let i = data.length - paddingLength; i < data.length; i += 1) { if (data[i] !== paddingLength) { - throw new Error('PKCS7 濉厖鍐呭闈炴硶') + throw new Error('PKCS7 填充内容非法') } } return data.subarray(0, data.length - paddingLength) @@ -1578,7 +1562,7 @@ export class ImageDecryptService { return true } - // 淇濈暀鍘熸湁鐨勬壒閲忔娴?XOR 瀵嗛挜鏂规硶锛堢敤浜庡吋瀹癸級 + // 保留原有的批量检测 XOR 密钥方法(用于兼容) async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise { const keyCount: Map = new Map() let filesChecked = 0 @@ -1656,18 +1640,18 @@ export class ImageDecryptService { } /** - * 瑙e寘 wxgf 鏍煎紡 - * wxgf 鏄井淇$殑鍥剧墖鏍煎紡锛屽唴閮ㄤ娇鐢?HEVC 缂栫爜 + * 解包 wxgf 格式 + * wxgf 是微信的图片格式,内部使用 HEVC 编码 */ private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> { - // 妫€鏌ユ槸鍚︽槸 wxgf 鏍煎紡 (77 78 67 66 = "wxgf") + // 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf") if (buffer.length < 20 || buffer[0] !== 0x77 || buffer[1] !== 0x78 || buffer[2] !== 0x67 || buffer[3] !== 0x66) { return { data: buffer, isWxgf: false } } - // 鍏堝皾璇曟悳绱㈠唴宓岀殑浼犵粺鍥剧墖绛惧悕 + // 先尝试搜索内嵌的传统图片签名 for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) { if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) { return { data: buffer.subarray(i), isWxgf: false } @@ -1678,20 +1662,20 @@ export class ImageDecryptService { } } - // 鎻愬彇 HEVC NALU 瑁告祦 + // 提取 HEVC NALU 裸流 const hevcData = this.extractHevcNalu(buffer) if (!hevcData || hevcData.length < 100) { return { data: buffer, isWxgf: true } } - // 灏濊瘯鐢?ffmpeg 杞崲 + // 尝试用 ffmpeg 转换 try { const jpgData = await this.convertHevcToJpg(hevcData) if (jpgData && jpgData.length > 0) { return { data: jpgData, isWxgf: false } } } catch { - // ffmpeg 杞崲澶辫触 + // ffmpeg 转换失败 } return { data: hevcData, isWxgf: true } @@ -1744,25 +1728,26 @@ export class ImageDecryptService { } /** - * 鑾峰彇 ffmpeg 鍙墽琛屾枃浠惰矾寰? */ + * 获取 ffmpeg 可执行文件路径 + */ private getFfmpegPath(): string { const staticPath = getStaticFfmpegPath() - this.logInfo('ffmpeg 璺緞妫€娴?, { staticPath, exists: staticPath ? existsSync(staticPath) : false }) + this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false }) if (staticPath) { return staticPath } - // 鍥為€€鍒扮郴缁?ffmpeg + // 回退到系统 ffmpeg return 'ffmpeg' } /** - * 浣跨敤 ffmpeg 灏?HEVC 瑁告祦杞崲涓?JPG + * 使用 ffmpeg 将 HEVC 裸流转换为 JPG */ private convertHevcToJpg(hevcData: Buffer): Promise { const ffmpeg = this.getFfmpegPath() - this.logInfo('ffmpeg 杞崲寮€濮?, { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) + this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) return new Promise((resolve) => { const { spawn } = require('child_process') @@ -1788,17 +1773,17 @@ export class ImageDecryptService { proc.on('close', (code: number) => { if (code === 0 && chunks.length > 0) { - this.logInfo('ffmpeg 杞崲鎴愬姛', { outputSize: Buffer.concat(chunks).length }) + this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length }) resolve(Buffer.concat(chunks)) } else { const errMsg = Buffer.concat(errChunks).toString() - this.logInfo('ffmpeg 杞崲澶辫触', { code, error: errMsg }) + this.logInfo('ffmpeg 转换失败', { code, error: errMsg }) resolve(null) } }) proc.on('error', (err: Error) => { - this.logInfo('ffmpeg 杩涚▼閿欒', { error: err.message }) + this.logInfo('ffmpeg 进程错误', { error: err.message }) resolve(null) }) @@ -1807,7 +1792,45 @@ export class ImageDecryptService { }) } - // 淇濈暀鍘熸湁鐨勮В瀵嗗埌鏂囦欢鏂规硶锛堢敤浜庡吋瀹癸級 + private looksLikeMd5(s: string): boolean { + return /^[a-f0-9]{32}$/i.test(s) + } + + private isThumbnailDat(name: string): boolean { + const lower = name.toLowerCase() + return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat') + } + + private hasXVariant(base: string): boolean { + const lower = base.toLowerCase() + return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t') + } + + private isHdPath(p: string): boolean { + return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h') + } + + private isThumbnailPath(p: string): boolean { + const lower = p.toLowerCase() + return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.') + } + + private sanitizeDirName(s: string): string { + return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown' + } + + private resolveTimeDir(filePath: string): string { + try { + const stats = statSync(filePath) + const d = new Date(stats.mtime) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + } catch { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + } + } + + // 保留原有的解密到文件方法(用于兼容) async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { const version = this.getDatVersion(inputPath) let decrypted: Buffer @@ -1819,7 +1842,7 @@ export class ImageDecryptService { decrypted = this.decryptDatV4(inputPath, xorKey, key) } else { if (!aesKey || aesKey.length !== 16) { - throw new Error('V4鐗堟湰闇€瑕?6瀛楄妭AES瀵嗛挜') + throw new Error('V4版本需要 16 字节 AES 密钥') } decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) }