发布:合并 dev 分支到 main

This commit is contained in:
hicccc77
2026-03-07 23:58:43 +08:00
7 changed files with 6460 additions and 177 deletions

View File

@@ -6,7 +6,6 @@ import * as https from 'https'
import * as http from 'http' import * as http from 'http'
import * as fzstd from 'fzstd' import * as fzstd from 'fzstd'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import Database from 'better-sqlite3'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
@@ -16,14 +15,9 @@ import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStat
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService' import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService' import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService'
import { LRUCache } from '../utils/LRUCache.js' import { LRUCache } from '../utils/LRUCache.js'
type HardlinkState = {
db: Database.Database
imageTable?: string
dirTable?: string
}
export interface ChatSession { export interface ChatSession {
username: string username: string
type: number type: number
@@ -213,11 +207,11 @@ class ChatService {
private avatarCache: Map<string, ContactCacheEntry> private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef' private readonly defaultV1AesKey = 'cfcd208495d565ef'
private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService private readonly messageCacheService: MessageCacheService
private readonly sessionStatsCacheService: SessionStatsCacheService private readonly sessionStatsCacheService: SessionStatsCacheService
private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService
private readonly imageDecryptService: ImageDecryptService
private voiceWavCache: LRUCache<string, Buffer> private voiceWavCache: LRUCache<string, Buffer>
private voiceTranscriptCache: LRUCache<string, string> private voiceTranscriptCache: LRUCache<string, string>
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>() private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
@@ -276,6 +270,7 @@ class ChatService {
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath()) this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath())
this.imageDecryptService = new ImageDecryptService()
// 初始化LRU缓存限制大小防止内存泄漏 // 初始化LRU缓存限制大小防止内存泄漏
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
@@ -4852,13 +4847,6 @@ class ChatService {
this.groupMyMessageCountCacheService.clearAll() this.groupMyMessageCountCacheService.clearAll()
} }
for (const state of this.hardlinkCache.values()) {
try {
state.db?.close()
} catch { }
}
this.hardlinkCache.clear()
if (includeEmojis) { if (includeEmojis) {
emojiCache.clear() emojiCache.clear()
emojiDownloading.clear() emojiDownloading.clear()
@@ -5502,59 +5490,33 @@ class ChatService {
const localId = parseInt(msgId, 10) const localId = parseInt(msgId, 10)
if (!this.connected) await this.connect() if (!this.connected) await this.connect()
// 1. 获取消息详情以拿到 MD5 和 AES Key // 1. 获取消息详情
const msgResult = await this.getMessageByLocalId(sessionId, localId) const msgResult = await this.getMessageByLocalId(sessionId, localId)
if (!msgResult.success || !msgResult.message) { if (!msgResult.success || !msgResult.message) {
return { success: false, error: '未找到消息' } return { success: false, error: '未找到消息' }
} }
const msg = msgResult.message const msg = msgResult.message
// 2. 确定搜索的基础名 // 2. 使用 imageDecryptService 解密图片
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId) const result = await this.imageDecryptService.decryptImage({
sessionId,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName || String(msg.localId),
force: false
})
// 3. 查找 .dat 文件 if (!result.success || !result.localPath) {
const myWxid = this.configService.get('myWxid') return { success: false, error: result.error || '图片解密失败' }
const dbPath = this.configService.get('dbPath')
if (!myWxid || !dbPath) return { success: false, error: '配置缺失' }
const accountDir = dirname(dirname(dbPath)) // dbPath 是 db_storage 里面的路径或同级
// 实际上 dbPath 指向 db_storageaccountDir 应该是其父目录
const actualAccountDir = this.resolveAccountDir(dbPath, myWxid)
if (!actualAccountDir) return { success: false, error: '无法定位账号目录' }
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
// 4. 获取解密密钥(优先使用当前 wxid 对应的密钥)
const imageKeys = this.configService.getImageKeysForCurrentWxid()
const xorKeyRaw = imageKeys.xorKey
const aesKeyRaw = imageKeys.aesKey || msg.aesKey
if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' }
const xorKey = this.parseXorKey(xorKeyRaw)
const data = readFileSync(datPath)
// 5. 解密
let decrypted: Buffer
const version = this.getDatVersion(data)
if (version === 0) {
decrypted = this.decryptDatV3(data, xorKey)
} else if (version === 1) {
const aesKey = this.asciiKey16(this.defaultV1AesKey)
decrypted = this.decryptDatV4(data, xorKey, aesKey)
} else {
const trimmed = String(aesKeyRaw ?? '').trim()
if (!trimmed || trimmed.length < 16) {
return { success: false, error: 'V4版本需要16字节AES密钥' }
}
const aesKey = this.asciiKey16(trimmed)
decrypted = this.decryptDatV4(data, xorKey, aesKey)
} }
// 返回 base64 // 3. 读取解密后的文件并转成 base64
return { success: true, data: decrypted.toString('base64') } // localPath 是 file:// URL需要转换成文件路径
const filePath = result.localPath.startsWith('file://')
? result.localPath.replace(/^file:\/\//, '')
: result.localPath
const imageData = readFileSync(filePath)
return { success: true, data: imageData.toString('base64') }
} catch (e) { } catch (e) {
console.error('ChatService: getImageData 失败:', e) console.error('ChatService: getImageData 失败:', e)
return { success: false, error: String(e) } return { success: false, error: String(e) }
@@ -6707,10 +6669,6 @@ class ChatService {
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> { private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
const normalized = this.normalizeDatBase(baseName) const normalized = this.normalizeDatBase(baseName)
if (this.looksLikeMd5(normalized)) {
const hardlinkPath = this.resolveHardlinkPath(accountDir, normalized, sessionId)
if (hardlinkPath) return hardlinkPath
}
const searchPaths = [ const searchPaths = [
join(accountDir, 'FileStorage', 'Image'), join(accountDir, 'FileStorage', 'Image'),
@@ -6776,68 +6734,6 @@ class ChatService {
return /[._][a-z]$/.test(baseLower) return /[._][a-z]$/.test(baseLower)
} }
private resolveHardlinkPath(accountDir: string, md5: string, sessionId?: string): string | null {
try {
const hardlinkPath = join(accountDir, 'hardlink.db')
if (!existsSync(hardlinkPath)) return null
const state = this.getHardlinkState(accountDir, hardlinkPath)
if (!state.imageTable) return null
const row = state.db
.prepare(`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE md5 = ? LIMIT 1`)
.get(md5) as { dir1?: string; dir2?: string; file_name?: string } | undefined
if (!row) return null
const dir1 = row.dir1 as string | undefined
const dir2 = row.dir2 as string | undefined
const fileName = row.file_name as string | undefined
if (!dir1 || !dir2 || !fileName) return null
const lowerFileName = fileName.toLowerCase()
if (lowerFileName.endsWith('.dat')) {
const baseLower = lowerFileName.slice(0, -4)
if (!this.hasXVariant(baseLower)) return null
}
let dirName = dir2
if (state.dirTable && sessionId) {
try {
const dirRow = state.db
.prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`)
.get(dir2, sessionId) as { dir_name?: string } | undefined
if (dirRow?.dir_name) dirName = dirRow.dir_name as string
} catch { }
}
const fullPath = join(accountDir, dir1, dirName, fileName)
if (existsSync(fullPath)) return fullPath
const withDat = `${fullPath}.dat`
if (existsSync(withDat)) return withDat
} catch { }
return null
}
private getHardlinkState(accountDir: string, hardlinkPath: string): HardlinkState {
const cached = this.hardlinkCache.get(accountDir)
if (cached) return cached
const db = new Database(hardlinkPath, { readonly: true, fileMustExist: true })
const imageRow = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1")
.get() as { name?: string } | undefined
const dirRow = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1")
.get() as { name?: string } | undefined
const state: HardlinkState = {
db,
imageTable: imageRow?.name as string | undefined,
dirTable: dirRow?.name as string | undefined
}
this.hardlinkCache.set(accountDir, state)
return state
}
private getDatVersion(data: Buffer): number { private getDatVersion(data: Buffer): number {
if (data.length < 6) return 0 if (data.length < 6) return 0
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])

View File

@@ -810,10 +810,20 @@ export class KeyService {
try { try {
// 1. 查找模板文件获取密文和 XOR 密钥 // 1. 查找模板文件获取密文和 XOR 密钥
onProgress?.('正在查找模板文件...') onProgress?.('正在查找模板文件...')
const { ciphertext, xorKey } = await this._findTemplateData(userDir) let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
// 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`) onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID // 2. 找微信 PID
const pid = await this.findWeChatPid() const pid = await this.findWeChatPid()
@@ -830,7 +840,7 @@ export class KeyService {
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
if (aesKey) { if (aesKey) {
onProgress?.('密钥获取成功') onProgress?.('密钥获取成功')
return { success: true, xorKey: xorKey ?? 0, aesKey } return { success: true, xorKey, aesKey }
} }
// 等 5 秒再试 // 等 5 秒再试
await new Promise(r => setTimeout(r, 5000)) await new Promise(r => setTimeout(r, 5000))
@@ -845,26 +855,26 @@ export class KeyService {
} }
} }
private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const { readdirSync, readFileSync, statSync } = await import('fs') const { readdirSync, readFileSync, statSync } = await import('fs')
const { join } = await import('path') const { join } = await import('path')
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
// 递归收集 *_t.dat 文件 // 递归收集 *_t.dat 文件
const collect = (dir: string, results: string[], limit = 32) => { const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= limit) return if (results.length >= maxFiles) return
try { try {
for (const entry of readdirSync(dir, { withFileTypes: true })) { for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= limit) break if (results.length >= maxFiles) break
const full = join(dir, entry.name) const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, limit) if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
} }
} catch { /* 忽略无权限目录 */ } } catch { /* 忽略无权限目录 */ }
} }
const files: string[] = [] const files: string[] = []
collect(userDir, files) collect(userDir, files, limit)
// 按修改时间降序 // 按修改时间降序
files.sort((a, b) => { files.sort((a, b) => {

View File

@@ -2,7 +2,6 @@
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { app } from 'electron' import { app } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
export interface VideoInfo { export interface VideoInfo {
@@ -71,58 +70,21 @@ class VideoService {
/** /**
* 从 video_hardlink_info_v4 表查询视频文件名 * 从 video_hardlink_info_v4 表查询视频文件名
* 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3 * 使用 wcdbService.execQuery 查询加密的 hardlink.db
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
*/ */
private async queryVideoFileName(md5: string): Promise<string | undefined> { private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid) const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath }) this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
if (!wxid) { if (!wxid) {
this.log('queryVideoFileName: wxid 为空') this.log('queryVideoFileName: wxid 为空')
return undefined return undefined
} }
// 方法1优先在 cachePath 下查找解密后的 hardlink.db // 使用 wcdbService.execQuery 查询加密的 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 {
this.log('尝试缓存 hardlink.db', { path: p })
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(/\.[^.]+$/, '')
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
this.log('缓存 hardlink.db 未命中', { path: p })
} catch (e) {
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) { if (dbPath) {
const dbPathLower = dbPath.toLowerCase() const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase() const wxidLower = wxid.toLowerCase()

View File

@@ -731,6 +731,7 @@ export class WcdbCore {
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult) console.error('WCDB 初始化失败:', initResult)
lastDllInitError = `初始化失败(错误码: ${initResult}`
return false return false
} }

View File

@@ -20,7 +20,6 @@
"electron:build": "npm run build" "electron:build": "npm run build"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0", "electron-store": "^10.0.0",
@@ -46,7 +45,6 @@
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^4.0.2", "@electron/rebuild": "^4.0.2",
"@types/better-sqlite3": "^7.6.13",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",

6416
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.