From f31886e1abebdb1a27a5d9ca5ff8f9c28a5133f0 Mon Sep 17 00:00:00 2001 From: hanyu Date: Thu, 26 Feb 2026 19:51:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E6=9C=AC=E5=9C=B0=20HTTP=20=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=AA=92=E4=BD=93=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=8E=92=E5=BA=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/httpService.ts | 103 +++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index b6168b6..86ea6c9 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -10,6 +10,7 @@ import { chatService, Message } from './chatService' import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { videoService } from './videoService' +import { imageDecryptService } from './imageDecryptService' // ChatLab 格式定义 interface ChatLabHeader { @@ -69,6 +70,7 @@ interface ApiExportedMedia { kind: MediaKind fileName: string fullPath: string + relativePath: string } // ChatLab 消息类型映射 @@ -236,6 +238,8 @@ class HttpService { await this.handleSessions(url, res) } else if (pathname === '/api/v1/contacts') { await this.handleContacts(url, res) + } else if (pathname.startsWith('/api/v1/media/')) { + this.handleMediaRequest(pathname, res) } else { this.sendError(res, 404, 'Not Found') } @@ -245,6 +249,40 @@ class HttpService { } } + private handleMediaRequest(pathname: string, res: http.ServerResponse): void { + const mediaBasePath = this.getApiMediaExportPath() + const relativePath = pathname.replace('/api/v1/media/', '') + const fullPath = path.join(mediaBasePath, relativePath) + + if (!fs.existsSync(fullPath)) { + this.sendError(res, 404, 'Media not found') + return + } + + const ext = path.extname(fullPath).toLowerCase() + const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.wav': 'audio/wav', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4' + } + const contentType = mimeTypes[ext] || 'application/octet-stream' + + try { + const fileBuffer = fs.readFileSync(fullPath) + res.setHeader('Content-Type', contentType) + res.setHeader('Content-Length', fileBuffer.length) + res.writeHead(200) + res.end(fileBuffer) + } catch (e) { + this.sendError(res, 500, 'Failed to read media file') + } + } + /** * 批量获取消息(循环游标直到满足 limit) * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 @@ -380,7 +418,7 @@ class HttpService { const queryOffset = keyword ? 0 : offset const queryLimit = keyword ? 10000 : limit - const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true) + const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false) if (!result.success || !result.messages) { this.sendError(res, 500, result.error || 'Failed to get messages') return @@ -576,19 +614,44 @@ class HttpService { ): Promise { try { if (msg.localType === 3 && options.exportImages) { - const result = await chatService.getImageData(talker, String(msg.localId)) - if (result.success && result.data) { - const imageBuffer = Buffer.from(result.data, 'base64') - const ext = this.detectImageExt(imageBuffer) - const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) - const fileName = `${fileBase}${ext}` - const targetDir = path.join(sessionDir, 'images') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, imageBuffer) + const result = await imageDecryptService.decryptImage({ + sessionId: talker, + imageMd5: msg.imageMd5, + imageDatName: msg.imageDatName, + force: true + }) + if (result.success && result.localPath) { + let imagePath = result.localPath + if (imagePath.startsWith('data:')) { + const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/) + if (base64Match) { + const imageBuffer = Buffer.from(base64Match[1], 'base64') + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.writeFileSync(fullPath, imageBuffer) + } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } + } + } else if (fs.existsSync(imagePath)) { + const imageBuffer = fs.readFileSync(imagePath) + const ext = this.detectImageExt(imageBuffer) + const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) + const fileName = `${fileBase}${ext}` + const targetDir = path.join(sessionDir, 'images') + const fullPath = path.join(targetDir, fileName) + this.ensureDir(targetDir) + if (!fs.existsSync(fullPath)) { + fs.copyFileSync(imagePath, fullPath) + } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` + return { kind: 'image', fileName, fullPath, relativePath } } - return { kind: 'image', fileName, fullPath } } } @@ -607,7 +670,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64')) } - return { kind: 'voice', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}` + return { kind: 'voice', fileName, fullPath, relativePath } } } @@ -622,7 +686,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.copyFileSync(info.videoUrl, fullPath) } - return { kind: 'video', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}` + return { kind: 'video', fileName, fullPath, relativePath } } } @@ -637,7 +702,8 @@ class HttpService { if (!fs.existsSync(fullPath)) { fs.copyFileSync(result.localPath, fullPath) } - return { kind: 'emoji', fileName, fullPath } + const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}` + return { kind: 'emoji', fileName, fullPath, relativePath } } } } catch (e) { @@ -661,7 +727,8 @@ class HttpService { parsedContent: msg.parsedContent, mediaType: media?.kind, mediaFileName: media?.fileName, - mediaPath: media?.fullPath + mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined, + mediaLocalPath: media?.fullPath } } @@ -784,7 +851,7 @@ class HttpService { type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), platformMessageId: msg.serverId ? String(msg.serverId) : undefined, - mediaPath: mediaMap.get(msg.localId)?.fullPath + mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined } }) From ec0eb64ffdaec039da40525d84c61cc5fe21a168 Mon Sep 17 00:00:00 2001 From: ace Date: Fri, 27 Feb 2026 11:07:54 +0800 Subject: [PATCH 2/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 f070d184eada66d74dd62b94850c1a7aa27d48c8 Mon Sep 17 00:00:00 2001 From: xuncha <102988462+xunchahaha@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:13:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86=E5=A4=B1=E8=B4=A5=E7=9A=84?= =?UTF-8?q?=E9=97=AE=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 | 497 +++++++++-------------- package-lock.json | 19 +- src/pages/ChatPage.tsx | 4 +- 9 files changed, 254 insertions(+), 509 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 88e73f8..8036e98 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 194480a..76ad1c7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -196,7 +196,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // 视频 video: { - getVideoInfo: (videoMd5: string, sessionId?: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, sessionId), + getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 845a1bd..be86d54 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4478,77 +4478,27 @@ class ChatService { } private resolveAccountDir(dbPath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase() - const normalized = dbPath.replace(/[\\/]+$/, '') + 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) }) + // 如果 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) } - // 检查 dbPath 本身是否就是账号目录 - if (this.isAccountDir(normalized)) { - candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) }) + // 否则,dbPath 应该是数据库根目录(如 xwechat_files) + // 账号目录应该是 {dbPath}/{wxid} + const accountDirWithWxid = join(normalized, wxid) + if (existsSync(accountDirWithWxid)) { + return accountDirWithWxid } - // 扫描 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 - } + // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) + return normalized } private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 122c33a..ee15b02 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -77,8 +77,7 @@ export class DbPathService { return ( existsSync(join(entryPath, 'db_storage')) || existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) || - existsSync(join(entryPath, 'msg', 'attach')) + existsSync(join(entryPath, 'FileStorage', 'Image2')) ) } @@ -95,21 +94,22 @@ export class DbPathService { const accountStat = statSync(entryPath) let latest = accountStat.mtimeMs - const checkSubDirs = [ - 'db_storage', - join('FileStorage', 'Image'), - join('FileStorage', 'Image2'), - join('msg', 'attach') - ] + const dbPath = join(entryPath, 'db_storage') + if (existsSync(dbPath)) { + const dbStat = statSync(dbPath) + latest = Math.max(latest, dbStat.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 { } - } + 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) } return latest diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 20f034b..7a8c043 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -329,78 +329,28 @@ export class ImageDecryptService { } 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 } /** diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index fb21b2f..34e7bd0 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -744,8 +744,7 @@ export class KeyService { return ( 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')) ) } @@ -762,8 +761,8 @@ export class KeyService { private listAccountDirs(rootDir: string): string[] { try { const entries = readdirSync(rootDir) - const candidates: { path: string; mtime: number; isAccount: boolean }[] = [] - + const high: string[] = [] + const low: string[] = [] for (const entry of entries) { const fullPath = join(rootDir, entry) try { @@ -776,45 +775,15 @@ export class KeyService { continue } - const isAccount = this.isAccountDir(fullPath) - candidates.push({ - path: fullPath, - mtime: this.getDirMtime(fullPath), - isAccount - }) - } - - // 优先选择有效账号目录,然后按修改时间从新到旧排序 - 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 { } + if (this.isAccountDir(fullPath)) { + high.push(fullPath) + } else { + low.push(fullPath) } } - - return mtime + return high.length ? high.sort() : low.sort() } catch { - return 0 + return [] } } diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 089142a..6b44c2e 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,266 @@ 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 { + /** + * 获取缓存目录(解密后的数据库存放位置) + */ + private getCachePath(): 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 } - 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) - } - /** - * 计算会话哈希(对应磁盘目录名) + * 从 video_hardlink_info_v4 表查询视频文件名 + * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) + * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db */ - private md5Hash(text: string): string { - return crypto.createHash('md5').update(text).digest('hex') - } + private async queryVideoFileName(md5: string): Promise { + const cachePath = this.getCachePath() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) - 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 (!wxid) return undefined - 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) + // 方法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 - } + 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(/\.[^.]+$/, '') + return realMd5 } + } catch (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) { + // 检查 dbPath 是否已经包含 wxid + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - 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) + 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 { + 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) { + // 忽略错误 } } - } 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 } - 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 { 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 }) + if (!dbPath || !wxid || !videoMd5) { 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 + + // 检查 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 (videoPath && existsSync(videoPath)) { - this.logInfo('定位成功', { videoPath }) - const base = videoPath.slice(0, -4) - const coverPath = `${base}.jpg` - const thumbPath = `${base}_thumb.jpg` + if (!existsSync(videoBaseDir)) { + return { exists: false } + } - return { - videoUrl: this.handleFile(videoPath, 'video', sessionId), - coverUrl: this.handleFile(coverPath, 'image', sessionId), - thumbUrl: this.handleFile(thumbPath, 'image', sessionId), - exists: true + // 遍历年月目录查找视频文件 + try { + const allDirs = readdirSync(videoBaseDir) + + // 支持多种目录格式: 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`) + + // 检查视频文件是否存在 + if (existsSync(videoPath)) { + return { + videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } } + } catch (e) { + // 忽略错误 } - this.logInfo('定位失败', { videoMd5 }) return { exists: false } } + /** + * 根据消息内容解析视频MD5 + */ parseVideoMd5(content: string): string | undefined { + + // 打印前500字符看看 XML 结构 + 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]}`) + } + + // 提取 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 } } diff --git a/package-lock.json b/package-lock.json index 4ac3dc4..92d10ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "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", @@ -2910,7 +2909,6 @@ "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" } @@ -3057,7 +3055,6 @@ "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", @@ -3997,7 +3994,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5107,7 +5103,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5295,7 +5290,6 @@ "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" @@ -5382,6 +5376,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5395,6 +5390,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5410,6 +5406,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5423,6 +5420,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9152,7 +9150,6 @@ "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" } @@ -9162,7 +9159,6 @@ "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" }, @@ -9597,7 +9593,6 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -10439,7 +10434,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10887,7 +10881,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10977,8 +10970,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11004,7 +10996,6 @@ "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 af71340..b5ec3df 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, session.username) + const result = await window.electronAPI.video.getVideoInfo(videoMd5) if (result && result.success && result.exists) { setVideoInfo({ exists: result.exists, @@ -3560,7 +3560,7 @@ function MessageBubble({ videoLoadingRef.current = false setVideoLoading(false) } - }, [videoMd5, session.username]) + }, [videoMd5]) // 视频进入视野时自动加载 useEffect(() => {