Files
CipherTalk/electron/services/dataManagementService.ts
T

1504 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs'
import * as path from 'path'
import { app, BrowserWindow } from 'electron'
import { ConfigService } from './config'
import { wechatDecryptService } from './decryptService'
import { imageDecryptService } from './imageDecryptService'
import { chatService } from './chatService'
// 文件系统监听器类型
type FileWatcher = fs.FSWatcher | null
export interface DatabaseFileInfo {
fileName: string
filePath: string
fileSize: number
wxid: string
isDecrypted: boolean
decryptedPath?: string
needsUpdate: boolean // 是否需要增量更新
originalModified?: number // 源文件修改时间戳
decryptedModified?: number // 解密文件修改时间戳
}
export interface ImageFileInfo {
fileName: string
filePath: string
fileSize: number
isDecrypted: boolean
decryptedPath?: string
version: number // 0=V3, 1=V4-V1, 2=V4-V2
}
class DataManagementService {
private configService: ConfigService
private dbWatcher: FileWatcher = null
private autoUpdateEnabled: boolean = false
private autoUpdateInterval: NodeJS.Timeout | null = null
private lastCheckTime: number = 0
private isUpdating: boolean = false
private silentMode: boolean = false
private updateListeners: Set<(hasUpdate: boolean) => void> = new Set()
private lastUpdateTime: number = 0
private pendingUpdateCount: number = 0 // 待处理的更新请求数
private updateQueue: Array<() => Promise<void>> = [] // 更新队列
private isProcessingQueue: boolean = false
constructor() {
this.configService = new ConfigService()
}
/**
* 扫描数据库文件
* 只扫描当前用户配置的 wxid 目录下的数据库
*/
async scanDatabases(): Promise<{ success: boolean; databases?: DatabaseFileInfo[]; error?: string }> {
try {
const databases: DatabaseFileInfo[] = []
// 获取配置的数据库路径
const dbPath = this.configService.get('dbPath')
if (!dbPath) {
return { success: false, error: '请先在设置页面配置数据库路径' }
}
// 获取配置的 wxid
const wxid = this.configService.get('myWxid')
if (!wxid) {
return { success: false, error: '请先在设置页面配置 wxid' }
}
// 获取缓存目录(优先使用配置的路径)
let cipherTalkDir = this.configService.get('cachePath')
if (!cipherTalkDir) {
cipherTalkDir = this.getDefaultCachePath()
}
// 检查路径是否存在
if (!fs.existsSync(dbPath)) {
return { success: false, error: `数据库路径不存在: ${dbPath}` }
}
// 智能识别路径类型
const pathParts = dbPath.split(path.sep)
const lastPart = pathParts[pathParts.length - 1]
if (lastPart === 'db_storage') {
// 直接选择了 db_storage 目录
const accountName = pathParts.length >= 2 ? this.cleanAccountDirName(pathParts[pathParts.length - 2]) : 'unknown'
await this.scanDbStorageDirectory(dbPath, accountName, cipherTalkDir, databases)
} else {
// 只扫描配置的 wxid 目录
// 先查找实际的账号目录名(可能包含后缀如 _bf70)
const actualAccountDir = this.findAccountDir(dbPath, wxid)
if (!actualAccountDir) {
return { success: false, error: `未找到账号目录: ${wxid}` }
}
const cleanedAccountName = this.cleanAccountDirName(actualAccountDir)
const dbStoragePath = path.join(dbPath, actualAccountDir, 'db_storage')
if (fs.existsSync(dbStoragePath)) {
await this.scanDbStorageDirectory(dbStoragePath, cleanedAccountName, cipherTalkDir, databases)
} else {
return { success: false, error: `账号目录下不存在 db_storage: ${dbStoragePath}` }
}
}
// 按文件大小排序
databases.sort((a, b) => a.fileSize - b.fileSize)
return { success: true, databases }
} catch (e) {
console.error('扫描数据库失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 扫描 db_storage 目录
*/
private async scanDbStorageDirectory(
dbStoragePath: string,
accountName: string,
cipherTalkDir: string,
databases: DatabaseFileInfo[]
): Promise<void> {
const dbFiles = this.findAllDbFiles(dbStoragePath)
for (const filePath of dbFiles) {
const fileName = path.basename(filePath)
const stats = fs.statSync(filePath)
const fileSize = stats.size
const originalModified = stats.mtimeMs
// 检查是否已解密
const decryptedFileName = fileName.replace(/\.db$/, '') + '.db'
const decryptedPath = path.join(cipherTalkDir, accountName, decryptedFileName)
const isDecrypted = fs.existsSync(decryptedPath)
let decryptedModified: number | undefined
let needsUpdate = false
if (isDecrypted) {
const decryptedStats = fs.statSync(decryptedPath)
decryptedModified = decryptedStats.mtimeMs
// 源文件比解密文件新,需要更新
needsUpdate = originalModified > decryptedModified
}
databases.push({
fileName,
filePath,
fileSize,
wxid: accountName,
isDecrypted,
decryptedPath,
needsUpdate,
originalModified,
decryptedModified
})
}
}
/**
* 递归查找所有 .db 文件
*/
private findAllDbFiles(dir: string): string[] {
const dbFiles: string[] = []
const scan = (currentDir: string) => {
try {
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name)
if (entry.isDirectory()) {
scan(fullPath)
} else if (entry.isFile() && entry.name.endsWith('.db')) {
dbFiles.push(fullPath)
}
}
} catch (e) {
// 忽略无法访问的目录
}
}
scan(dir)
return dbFiles
}
/**
* 清理账号目录名
* 微信账号目录格式多样:
* - wxid_xxxxx(传统格式)
* - 纯数字(QQ号绑定)
* - 自定义微信号格式(如 chenggongyouyue003_03d9
*
* 注意:不再去除后缀,因为自定义微信号本身可能包含下划线
*/
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
// wxid_ 开头的账号,提取主要部分(去除可能的随机后缀)
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[a-zA-Z0-9]+)/i)
if (match) return match[1]
return trimmed
}
// 自定义微信号或其他格式,直接返回(不做处理)
// 因为自定义微信号本身可能包含下划线,如 chenggongyouyue003_03d9
return trimmed
}
/**
* 查找账号对应的实际目录名
* 因为目录名可能是 wxid_xxx、abc123 或 abc123_xxxx 等格式
* 支持多种匹配方式以兼容不同版本的目录命名
*/
private findAccountDir(baseDir: string, wxid: string): string | null {
if (!fs.existsSync(baseDir)) return null
const cleanedWxid = this.cleanAccountDirName(wxid)
// 1. 直接匹配原始 wxid
const directPath = path.join(baseDir, wxid)
if (fs.existsSync(directPath)) {
return wxid
}
// 2. 直接匹配清理后的 wxid
if (cleanedWxid !== wxid) {
const cleanedPath = path.join(baseDir, cleanedWxid)
if (fs.existsSync(cleanedPath)) {
return cleanedWxid
}
}
// 3. 扫描目录,查找匹配的账号目录
try {
const entries = fs.readdirSync(baseDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dirName = entry.name
const dirNameLower = dirName.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
// 精确匹配(忽略大小写)
if (dirNameLower === wxidLower || dirNameLower === cleanedWxidLower) {
return dirName
}
// 前缀匹配
if (dirNameLower.startsWith(wxidLower + '_') || dirNameLower.startsWith(cleanedWxidLower + '_')) {
return dirName
}
// 反向前缀匹配
if (wxidLower.startsWith(dirNameLower + '_') || cleanedWxidLower.startsWith(dirNameLower + '_')) {
return dirName
}
// 清理后匹配
const cleanedDirName = this.cleanAccountDirName(dirName)
if (cleanedDirName.toLowerCase() === wxidLower || cleanedDirName.toLowerCase() === cleanedWxidLower) {
return dirName
}
}
} catch (e) {
console.error('查找账号目录失败:', e)
}
return null
}
/**
* 批量解密所有待解密的数据库
*/
async decryptAll(): Promise<{ success: boolean; successCount?: number; failCount?: number; error?: string }> {
try {
const scanResult = await this.scanDatabases()
if (!scanResult.success || !scanResult.databases) {
return { success: false, error: scanResult.error || '扫描数据库失败' }
}
const pendingFiles = scanResult.databases.filter(db => !db.isDecrypted)
if (pendingFiles.length === 0) {
return { success: true, successCount: 0, failCount: 0 }
}
const key = this.configService.get('decryptKey')
if (!key) {
return { success: false, error: '请先在设置页面配置解密密钥' }
}
let successCount = 0
let failCount = 0
const totalFiles = pendingFiles.length
for (let i = 0; i < pendingFiles.length; i++) {
const file = pendingFiles[i]
const time = new Date().toLocaleTimeString()
console.log(`[${time}] [数据解密] 正在解密: ${file.fileName} (${i + 1}/${totalFiles})`)
// 发送进度到前端
this.sendProgress({
type: 'decrypt',
current: i,
total: totalFiles,
fileName: file.fileName,
fileProgress: 0
})
const outputDir = path.dirname(file.decryptedPath!)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
const result = await wechatDecryptService.decryptDatabase(
file.filePath,
file.decryptedPath!,
key,
(current, total) => {
this.sendProgress({
type: 'decrypt',
current: i,
total: totalFiles,
fileName: file.fileName,
fileProgress: Math.round((current / total) * 100)
})
}
)
if (result.success) {
successCount++
const time = new Date().toLocaleTimeString()
console.log(`[${time}] [数据解密] 解密成功: ${file.fileName}`)
} else {
failCount++
const time = new Date().toLocaleTimeString()
console.error(`[${time}] [数据解密] 解密失败: ${file.fileName}`, result.error)
}
// 关键:强制让出主线程时间片,防止批量处理时 UI 卡死
// 即使是 Worker 解密,连续的 IPC 通信和主线程调度也会导致卡顿
await new Promise(resolve => setTimeout(resolve, 10))
}
// 完成
this.sendProgress({ type: 'complete' })
// 刷新 chatService 的缓存,让下次访问时重新扫描数据库
chatService.refreshMessageDbCache()
return { success: true, successCount, failCount }
} catch (e) {
console.error('批量解密失败:', e)
this.sendProgress({ type: 'error', error: String(e) })
return { success: false, error: String(e) }
}
}
/**
* 增量更新(只更新有变化的文件)
*/
async incrementalUpdate(silent: boolean = false): Promise<{ success: boolean; successCount?: number; failCount?: number; error?: string }> {
// 设置静默模式
const previousSilentMode = this.silentMode
this.silentMode = silent
try {
const scanResult = await this.scanDatabases()
if (!scanResult.success || !scanResult.databases) {
return { success: false, error: scanResult.error || '扫描数据库失败' }
}
const filesToUpdate = scanResult.databases.filter(db => db.needsUpdate)
if (filesToUpdate.length === 0) {
return { success: true, successCount: 0, failCount: 0 }
}
const key = this.configService.get('decryptKey')
if (!key) {
return { success: false, error: '请先在设置页面配置解密密钥' }
}
// 不再关闭整个 chatService,而是在更新每个文件前只关闭那个特定的数据库
// 这样用户可以在增量更新时继续查看其他会话的消息
imageDecryptService.clearHardlinkCache()
let successCount = 0
let failCount = 0
const totalFiles = filesToUpdate.length
for (let i = 0; i < filesToUpdate.length; i++) {
const file = filesToUpdate[i]
// 在处理每个文件前让出时间片,避免阻塞UI
const time = new Date().toLocaleTimeString()
// console.log(`[${time}] [增量同步] 正在同步数据库: ${file.fileName} (${i + 1}/${totalFiles})`) // 减少日志
if (i > 0) {
await new Promise(resolve => setImmediate(resolve))
}
this.sendProgress({
type: 'update',
current: i,
total: totalFiles,
fileName: file.fileName,
fileProgress: 0
})
// 检查源文件是否存在且可读
if (!fs.existsSync(file.filePath)) {
console.warn(`源文件不存在: ${file.filePath}`)
failCount++
continue
}
// 尝试读取源文件的前几个字节,检查文件是否可读
try {
const fd = fs.openSync(file.filePath, 'r')
fs.closeSync(fd)
} catch (e) {
console.warn(`源文件无法读取: ${file.filePath}`, e)
failCount++
continue
}
const backupPath = file.decryptedPath + '.old.' + Date.now()
// 在备份/覆盖文件前,先关闭该数据库的连接,释放文件锁
chatService.closeDatabase(file.fileName)
// 等待文件句柄释放
await new Promise(resolve => setTimeout(resolve, 100))
if (fs.existsSync(file.decryptedPath!)) {
// 尝试备份文件,如果失败则重试几次
let backupSuccess = false
const maxRetries = 3
for (let retry = 0; retry < maxRetries; retry++) {
try {
// 如果是 hardlink.db,再次清理缓存
if (file.fileName.toLowerCase().includes('hardlink')) {
imageDecryptService.clearHardlinkCache()
await new Promise(resolve => setTimeout(resolve, 200))
}
fs.renameSync(file.decryptedPath!, backupPath)
backupSuccess = true
break
} catch (e: any) {
if (e.code === 'EBUSY' || e.code === 'EPERM') {
// 文件被占用,等待后重试
if (retry < maxRetries - 1) {
// console.warn(`备份文件失败(重试 ${retry + 1}/${maxRetries}: ${file.fileName}`, e.code)
await new Promise(resolve => setTimeout(resolve, 500 * (retry + 1)))
} else {
// console.error(`备份旧文件失败(已重试 ${maxRetries} 次),将尝试直接覆盖: ${file.fileName}`, e)
// 即使备份失败,也不中断,尝试直接覆盖原文件
// 很多时候 rename 失败是因为杀毒软件扫描或文件锁定,但写入可能仍有机会成功
}
} else {
// 非文件占用错误,记录并继续尝试
console.error(`备份文件遇到非锁定错误: ${file.fileName}`, e)
}
}
}
if (!backupSuccess) {
// 重试失败,跳过这个文件
continue
}
}
const result = await wechatDecryptService.decryptDatabase(
file.filePath,
file.decryptedPath!,
key,
(current, total) => {
this.sendProgress({
type: 'update',
current: i,
total: totalFiles,
fileName: file.fileName,
fileProgress: Math.round((current / total) * 100)
})
}
)
// 验证解密后的文件是否完整(FTS 数据库跳过完整性检查)
const isFtsDb = file.fileName.toLowerCase().includes('fts') || file.fileName.toLowerCase().includes('_fts')
const skipIntegrityCheck = this.configService.get('skipIntegrityCheck') === true
if (result.success && fs.existsSync(file.decryptedPath!) && !isFtsDb && !skipIntegrityCheck) {
try {
// 尝试打开解密后的数据库文件,验证完整性
// 使用异步方式,避免阻塞主线程
const Database = require('better-sqlite3')
const testDb = new Database(file.decryptedPath!, { readonly: true })
// 让出时间片,避免阻塞UI
await new Promise(resolve => setImmediate(resolve))
const integrityResult = testDb.prepare('PRAGMA integrity_check').get() as any
testDb.close()
// 再次让出时间片
await new Promise(resolve => setImmediate(resolve))
// 检查完整性结果
if (integrityResult && typeof integrityResult === 'object' && integrityResult['integrity_check'] !== 'ok') {
throw new Error('数据库完整性检查失败')
}
} catch (integrityError: any) {
// 只对真正的损坏错误进行处理,忽略 FTS 数据库的逻辑错误
if (integrityError?.code === 'SQLITE_CORRUPT' || integrityError?.message?.includes('malformed')) {
console.error(`解密后的数据库文件损坏: ${file.decryptedPath}`, integrityError)
// 关闭可能占用文件的连接
chatService.close()
await new Promise(resolve => setTimeout(resolve, 200))
// 恢复备份(先删除损坏文件,再重命名备份)
if (fs.existsSync(backupPath)) {
try {
// 先尝试删除损坏的文件
if (fs.existsSync(file.decryptedPath!)) {
try {
fs.unlinkSync(file.decryptedPath!)
} catch (e) {
// 如果删除失败,尝试重命名
const corruptedPath = file.decryptedPath! + '.corrupted.' + Date.now()
try {
fs.renameSync(file.decryptedPath!, corruptedPath)
} catch { }
}
}
// 然后恢复备份
fs.renameSync(backupPath, file.decryptedPath!)
console.log(`已恢复备份文件: ${file.fileName}`)
} catch (e: any) {
console.error(`恢复备份失败: ${file.fileName}`, e)
// 如果恢复失败,记录错误但继续处理其他文件
}
}
failCount++
continue
} else {
// 其他错误(如 SQL logic error)可能是 FTS 数据库的正常情况,记录但不失败
console.warn(`数据库验证警告(可能正常): ${file.fileName}`, integrityError?.code || integrityError?.message)
}
}
}
if (result.success) {
successCount++
// const time = new Date().toLocaleTimeString()
// console.log(`[${time}] [增量同步] 同步成功: ${file.fileName}`) // 减少日志
if (fs.existsSync(backupPath)) {
try { fs.unlinkSync(backupPath) } catch { }
}
} else {
failCount++
const time = new Date().toLocaleTimeString()
console.error(`[${time}] [增量同步] 同步失败: ${file.fileName}`, result.error)
if (fs.existsSync(backupPath)) {
try { fs.renameSync(backupPath, file.decryptedPath!) } catch { }
}
}
// 关键:强制让出主线程时间片,防止批量处理时 UI 卡死
await new Promise(resolve => setTimeout(resolve, 10))
}
this.sendProgress({ type: 'complete' })
// 刷新 chatService 的缓存,让下次访问时重新扫描数据库
chatService.refreshMessageDbCache()
return { success: true, successCount, failCount }
} catch (e) {
const time = new Date().toLocaleTimeString()
console.error(`[${time}] [增量同步] 过程出现异常:`, e)
this.sendProgress({ type: 'error', error: String(e) })
return { success: false, error: String(e) }
} finally {
// 恢复之前的静默模式状态
this.silentMode = previousSilentMode
}
}
/**
* 发送进度到前端(发送到所有窗口,确保主窗口能收到)
*/
private sendProgress(data: any) {
// 如果当前是静默模式,不发送进度事件
if (this.silentMode) {
return
}
const windows = BrowserWindow.getAllWindows()
for (const win of windows) {
if (!win.isDestroyed()) {
win.webContents.send('dataManagement:progress', data)
}
}
}
/**
* 获取当前缓存目录
*/
getCurrentCachePath(): string {
const cachePath = this.configService.get('cachePath')
if (cachePath) return cachePath
return this.getDefaultCachePath()
}
/**
* 获取默认缓存目录
* - 开发环境:使用文档目录
* - 生产环境:
* - 如果安装在 C 盘:使用文档目录(C 盘可能有写入权限问题)
* - 如果安装在其他盘:使用软件安装目录
*/
getDefaultCachePath(): string {
// 开发环境使用文档目录
if (process.env.VITE_DEV_SERVER_URL) {
const documentsPath = app.getPath('documents')
return path.join(documentsPath, 'CipherTalkData')
}
// 生产环境
const exePath = app.getPath('exe')
const installDir = path.dirname(exePath)
// 检查是否安装在 C 盘(Windows)
const isOnCDrive = /^[cC]:/i.test(installDir) || installDir.startsWith('\\')
if (isOnCDrive) {
// C 盘可能有写入权限问题,使用文档目录
const documentsPath = app.getPath('documents')
return path.join(documentsPath, 'CipherTalkData')
}
// 其他盘使用软件安装目录
return path.join(installDir, 'CipherTalkData')
}
/**
* 迁移缓存到新目录
*/
async migrateCache(newCachePath: string): Promise<{ success: boolean; movedCount?: number; error?: string }> {
try {
// 检查可能存在数据的目录:配置的路径和默认路径
const configuredPath = this.configService.get('cachePath')
const defaultPath = this.getDefaultCachePath()
// 确定实际的旧缓存目录(优先检查默认路径是否有数据)
let oldCachePath: string | null = null
// 如果默认路径存在且有内容,优先迁移它
if (fs.existsSync(defaultPath) && fs.readdirSync(defaultPath).length > 0) {
oldCachePath = defaultPath
}
// 否则检查配置的路径
else if (configuredPath && fs.existsSync(configuredPath) && fs.readdirSync(configuredPath).length > 0) {
oldCachePath = configuredPath
}
if (!oldCachePath) {
// 没有找到需要迁移的数据,直接创建新目录
fs.mkdirSync(newCachePath, { recursive: true })
return { success: true, movedCount: 0 }
}
if (oldCachePath === newCachePath) {
return { success: false, error: '新旧目录相同,无需迁移' }
}
console.log(`迁移缓存: ${oldCachePath} -> ${newCachePath}`)
// 确保新目录存在
fs.mkdirSync(newCachePath, { recursive: true })
// 获取旧目录下的所有文件和文件夹
const entries = fs.readdirSync(oldCachePath, { withFileTypes: true })
let movedCount = 0
for (const entry of entries) {
const oldPath = path.join(oldCachePath, entry.name)
const newPath = path.join(newCachePath, entry.name)
this.sendProgress({
type: 'migrate',
fileName: entry.name,
current: movedCount,
total: entries.length
})
try {
if (entry.isDirectory()) {
// 递归复制目录
await this.copyDirectory(oldPath, newPath)
} else {
// 复制文件
fs.copyFileSync(oldPath, newPath)
}
movedCount++
} catch (e) {
console.error(`迁移失败: ${entry.name}`, e)
}
}
// 删除旧目录
try {
fs.rmSync(oldCachePath, { recursive: true, force: true })
} catch (e) {
console.warn('删除旧缓存目录失败:', e)
}
this.sendProgress({ type: 'complete' })
return { success: true, movedCount }
} catch (e) {
console.error('缓存迁移失败:', e)
this.sendProgress({ type: 'error', error: String(e) })
return { success: false, error: String(e) }
}
}
/**
* 递归复制目录
*/
private async copyDirectory(src: string, dest: string): Promise<void> {
fs.mkdirSync(dest, { recursive: true })
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
/**
* 扫描图片文件(递归扫描整个账号目录,流式返回)
*/
async scanImages(accountDir: string): Promise<{ success: boolean; images?: ImageFileInfo[]; error?: string }> {
try {
if (!fs.existsSync(accountDir)) {
return { success: false, error: `目录不存在: ${accountDir}` }
}
const images: ImageFileInfo[] = []
const cachePath = this.getCurrentCachePath()
const imageOutputDir = path.join(cachePath, 'images')
// 图片变体后缀(用于识别图片文件)
const imageSuffixes = ['.b', '.h', '.t', '.c', '.w', '.l', '_b', '_h', '_t', '_c', '_w', '_l']
let batchImages: ImageFileInfo[] = []
const BATCH_SIZE = 100
let processedCount = 0
const flushBatch = () => {
if (batchImages.length > 0) {
this.sendProgress({
type: 'imageBatch',
images: [...batchImages]
})
batchImages = []
}
}
// 让出事件循环,避免阻塞 UI
const yieldToMain = () => new Promise<void>(resolve => setImmediate(resolve))
const scanDir = async (dir: string): Promise<void> => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
// 跳过数据库目录
if (entry.name === 'db_storage' || entry.name === 'database') continue
await scanDir(fullPath)
} else if (entry.name.endsWith('.dat')) {
// 检查是否是图片文件(通过后缀识别)
const baseName = path.basename(entry.name, '.dat').toLowerCase()
const isImageFile = imageSuffixes.some(suffix => baseName.endsWith(suffix))
if (!isImageFile) continue
try {
const stats = fs.statSync(fullPath)
// 跳过太小的文件
if (stats.size < 100) continue
// 检测版本
const version = imageDecryptService.getDatVersion(fullPath)
// 计算相对路径用于输出(保持目录结构)
const relativePath = path.relative(accountDir, fullPath)
const outputRelativePath = relativePath.replace(/\.dat$/, '')
// 检查是否已解密(检查常见图片格式)
let isDecrypted = false
let decryptedPath: string | undefined
for (const ext of ['.jpg', '.png', '.gif', '.bmp', '.webp']) {
const possiblePath = path.join(imageOutputDir, outputRelativePath + ext)
if (fs.existsSync(possiblePath)) {
isDecrypted = true
decryptedPath = possiblePath
break
}
}
const imageInfo: ImageFileInfo = {
fileName: entry.name,
filePath: fullPath,
fileSize: stats.size,
isDecrypted,
decryptedPath,
version
}
images.push(imageInfo)
batchImages.push(imageInfo)
processedCount++
// 每 BATCH_SIZE 个发送一次,并让出事件循环
if (batchImages.length >= BATCH_SIZE) {
flushBatch()
await yieldToMain()
}
} catch {
// 忽略无法访问的文件
}
}
}
} catch {
// 忽略无法访问的目录
}
}
await scanDir(accountDir)
// 发送剩余的
flushBatch()
// 按文件大小排序
images.sort((a, b) => a.fileSize - b.fileSize)
// 扫描完成通知
this.sendProgress({
type: 'imageScanComplete',
total: images.length
})
return { success: true, images }
} catch (e) {
console.error('扫描图片失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 静默扫描图片(不发送事件,用于批量解密)
*/
private async scanImagesQuiet(accountDir: string): Promise<ImageFileInfo[]> {
const images: ImageFileInfo[] = []
const cachePath = this.getCurrentCachePath()
const imageOutputDir = path.join(cachePath, 'images')
const imageSuffixes = ['.b', '.h', '.t', '.c', '.w', '.l', '_b', '_h', '_t', '_c', '_w', '_l']
const scanDir = (dir: string): void => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === 'db_storage' || entry.name === 'database') continue
scanDir(fullPath)
} else if (entry.name.endsWith('.dat')) {
const baseName = path.basename(entry.name, '.dat').toLowerCase()
const isImageFile = imageSuffixes.some(suffix => baseName.endsWith(suffix))
if (!isImageFile) continue
try {
const stats = fs.statSync(fullPath)
if (stats.size < 100) continue
const version = imageDecryptService.getDatVersion(fullPath)
const relativePath = path.relative(accountDir, fullPath)
const outputRelativePath = relativePath.replace(/\.dat$/, '')
let isDecrypted = false
for (const ext of ['.jpg', '.png', '.gif', '.bmp', '.webp']) {
const possiblePath = path.join(imageOutputDir, outputRelativePath + ext)
if (fs.existsSync(possiblePath)) {
isDecrypted = true
break
}
}
// 只添加未解密的图片
if (!isDecrypted) {
images.push({
fileName: entry.name,
filePath: fullPath,
fileSize: stats.size,
isDecrypted: false,
version
})
}
} catch {
// 忽略
}
}
}
} catch {
// 忽略
}
}
scanDir(accountDir)
return images
}
/**
* 批量解密图片
*/
async decryptImages(accountDir: string): Promise<{ success: boolean; successCount?: number; failCount?: number; error?: string }> {
try {
// 获取密钥
const xorKeyStr = this.configService.get('imageXorKey')
const aesKeyStr = this.configService.get('imageAesKey')
if (!xorKeyStr) {
return { success: false, error: '请先在设置页面配置图片 XOR 密钥' }
}
const xorKey = parseInt(String(xorKeyStr), 16)
if (isNaN(xorKey)) {
return { success: false, error: 'XOR 密钥格式错误' }
}
// 静默扫描图片(不发送事件到前端)
console.log('开始扫描待解密图片...')
const pendingImages = await this.scanImagesQuiet(accountDir)
console.log(`找到 ${pendingImages.length} 个待解密图片`)
if (pendingImages.length === 0) {
return { success: true, successCount: 0, failCount: 0 }
}
const cachePath = this.getCurrentCachePath()
const imageOutputDir = path.join(cachePath, 'images')
// 确保输出目录存在
if (!fs.existsSync(imageOutputDir)) {
fs.mkdirSync(imageOutputDir, { recursive: true })
}
let successCount = 0
let failCount = 0
const totalFiles = pendingImages.length
const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : Buffer.alloc(16)
// 分批处理,每批 50 个,避免内存溢出
const BATCH_SIZE = 50
for (let i = 0; i < pendingImages.length; i++) {
const img = pendingImages[i]
// 每 10 个更新一次进度,减少 IPC 通信
if (i % 10 === 0 || i === pendingImages.length - 1) {
this.sendProgress({
type: 'image',
current: i,
total: totalFiles,
fileName: img.fileName,
fileProgress: Math.round(((i + 1) / totalFiles) * 100)
})
}
try {
// 计算输出路径(保持目录结构)
const relativePath = path.relative(accountDir, img.filePath)
const outputRelativePath = relativePath.replace(/\.dat$/, '')
// 解密图片
const decrypted = imageDecryptService.decryptDatFile(img.filePath, xorKey, aesKeyBuffer)
// 检测图片格式
const ext = this.detectImageFormat(decrypted)
const outputPath = path.join(imageOutputDir, outputRelativePath + ext)
// 确保输出目录存在
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(outputPath, decrypted)
successCount++
} catch (e) {
// 静默失败,不打印每个错误
failCount++
}
// 每批处理完后让出事件循环,避免阻塞
if ((i + 1) % BATCH_SIZE === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
this.sendProgress({ type: 'complete' })
console.log(`批量解密完成: 成功 ${successCount}, 失败 ${failCount}`)
return { success: true, successCount, failCount }
} catch (e) {
console.error('批量解密图片失败:', e)
this.sendProgress({ type: 'error', error: String(e) })
return { success: false, error: String(e) }
}
}
/**
* 获取图片目录(根据 dbPath 自动推断,返回账号根目录)
*/
getImageDirectories(): { success: boolean; directories?: { wxid: string; path: string }[]; error?: string } {
try {
const dbPath = this.configService.get('dbPath')
if (!dbPath) {
return { success: false, error: '请先在设置页面配置数据库路径' }
}
if (!fs.existsSync(dbPath)) {
return { success: false, error: `数据库路径不存在: ${dbPath}` }
}
const directories: { wxid: string; path: string }[] = []
// 智能识别路径类型
const pathParts = dbPath.split(path.sep)
const lastPart = pathParts[pathParts.length - 1]
if (lastPart === 'db_storage') {
// 直接选择了 db_storage 目录,往上找账号根目录
const accountDir = path.dirname(dbPath)
const wxid = this.cleanAccountDirName(path.basename(accountDir))
directories.push({ wxid, path: accountDir })
} else {
// 扫描该目录下所有账号目录
const entries = fs.readdirSync(dbPath, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const accountDirName = entry.name
const dbStoragePath = path.join(dbPath, accountDirName, 'db_storage')
// 检查是否存在 db_storage 子目录(确认是微信账号目录)
if (fs.existsSync(dbStoragePath)) {
const wxid = this.cleanAccountDirName(accountDirName)
directories.push({ wxid, path: path.join(dbPath, accountDirName) })
}
}
}
if (directories.length === 0) {
return { success: false, error: '未找到微信账号目录,请确认数据库路径配置正确' }
}
return { success: true, directories }
} catch (e) {
console.error('获取图片目录失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 单个图片解密
*/
async decryptSingleImage(filePath: string): Promise<{ success: boolean; outputPath?: string; error?: string }> {
try {
// 获取密钥
const xorKeyStr = this.configService.get('imageXorKey')
const aesKeyStr = this.configService.get('imageAesKey')
if (!xorKeyStr) {
return { success: false, error: '请先在设置页面配置图片 XOR 密钥' }
}
const xorKey = parseInt(String(xorKeyStr), 16)
if (isNaN(xorKey)) {
return { success: false, error: 'XOR 密钥格式错误' }
}
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' }
}
// 获取 dbPath 来计算相对路径
const dbPath = this.configService.get('dbPath')
if (!dbPath) {
return { success: false, error: '请先配置数据库路径' }
}
// 找到账号根目录
const pathParts = dbPath.split(path.sep)
const lastPart = pathParts[pathParts.length - 1]
let accountDir: string
if (lastPart === 'db_storage') {
accountDir = path.dirname(dbPath)
} else {
// 从文件路径中提取账号目录
const filePathParts = filePath.split(path.sep)
const dbPathIndex = filePathParts.findIndex((p, i) =>
i > 0 && filePathParts.slice(0, i).join(path.sep) === dbPath
)
if (dbPathIndex > 0) {
accountDir = filePathParts.slice(0, dbPathIndex + 1).join(path.sep)
} else {
// 尝试从文件路径推断
accountDir = path.dirname(filePath)
while (accountDir !== path.dirname(accountDir)) {
if (fs.existsSync(path.join(accountDir, 'db_storage'))) {
break
}
accountDir = path.dirname(accountDir)
}
}
}
const cachePath = this.getCurrentCachePath()
const imageOutputDir = path.join(cachePath, 'images')
// 计算输出路径
const relativePath = path.relative(accountDir, filePath)
const outputRelativePath = relativePath.replace(/\.dat$/, '')
// 解密图片
const aesKeyBuffer = aesKeyStr ? imageDecryptService.asciiKey16(String(aesKeyStr)) : undefined
console.log('解密图片:', filePath)
console.log('XOR Key:', xorKey.toString(16))
console.log('AES Key String:', aesKeyStr)
console.log('AES Key Buffer:', aesKeyBuffer?.toString('hex'))
console.log('图片版本:', imageDecryptService.getDatVersion(filePath))
const decrypted = imageDecryptService.decryptDatFile(filePath, xorKey, aesKeyBuffer || Buffer.alloc(16))
// 检测图片格式
const ext = this.detectImageFormat(decrypted)
const outputPath = path.join(imageOutputDir, outputRelativePath + ext)
// 确保输出目录存在
const outputDir = path.dirname(outputPath)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
fs.writeFileSync(outputPath, decrypted)
return { success: true, outputPath }
} catch (e) {
console.error('解密单个图片失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 检测图片格式
*/
private detectImageFormat(data: Buffer): string {
if (data.length < 4) return '.bin'
// JPEG: FF D8 FF
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
return '.jpg'
}
// PNG: 89 50 4E 47
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47) {
return '.png'
}
// GIF: 47 49 46 38
if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38) {
return '.gif'
}
// BMP: 42 4D
if (data[0] === 0x42 && data[1] === 0x4D) {
return '.bmp'
}
// WebP: 52 49 46 46 ... 57 45 42 50
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46) {
if (data.length >= 12 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
return '.webp'
}
}
return '.bin'
}
/**
* 检查是否有需要更新的数据库(不执行更新,只检查)
*/
async checkForUpdates(): Promise<{ hasUpdate: boolean; updateCount?: number; error?: string }> {
try {
const scanResult = await this.scanDatabases()
if (!scanResult.success || !scanResult.databases) {
return { hasUpdate: false, error: scanResult.error }
}
const filesToUpdate = scanResult.databases.filter(db => db.needsUpdate)
return { hasUpdate: filesToUpdate.length > 0, updateCount: filesToUpdate.length }
} catch (e) {
return { hasUpdate: false, error: String(e) }
}
}
/**
* 启用自动更新(文件监听 + 定时检查)
*/
enableAutoUpdate(intervalSeconds: number = 30): void {
if (this.autoUpdateEnabled) {
this.disableAutoUpdate()
}
this.autoUpdateEnabled = true
this.lastCheckTime = Date.now()
// 启动文件系统监听(实时检测,立即生效)
this.startFileWatcher()
// 启动定时检查(作为备选方案,仅在文件监听失效时使用)
this.autoUpdateInterval = setInterval(async () => {
if (this.isUpdating) return
const checkResult = await this.checkForUpdates()
if (checkResult.hasUpdate) {
// 通知监听器
this.updateListeners.forEach(listener => listener(true))
}
}, intervalSeconds * 1000)
}
/**
* 禁用自动更新
*/
disableAutoUpdate(): void {
this.autoUpdateEnabled = false
// 停止文件监听
if (this.dbWatcher) {
this.dbWatcher.close()
this.dbWatcher = null
}
// 停止定时检查
if (this.autoUpdateInterval) {
clearInterval(this.autoUpdateInterval)
this.autoUpdateInterval = null
}
console.log('[DataManagement] 自动更新已禁用')
}
/**
* 启动文件系统监听
*/
private startFileWatcher(): void {
const dbPath = this.configService.get('dbPath')
if (!dbPath) return
try {
// 智能查找 db_storage 目录
let dbStoragePath: string | null = null
// 1. 检查 dbPath 本身是否是 db_storage
if (path.basename(dbPath).toLowerCase() === 'db_storage' && fs.existsSync(dbPath)) {
dbStoragePath = dbPath
}
// 2. 检查 dbPath/db_storage
else if (fs.existsSync(path.join(dbPath, 'db_storage'))) {
dbStoragePath = path.join(dbPath, 'db_storage')
}
// 3. 检查 dbPath/[wxid]/db_storage(如果配置了 wxid
else {
const myWxid = this.configService.get('myWxid')
if (myWxid) {
// 尝试直接路径
const wxidDbStorage = path.join(dbPath, myWxid, 'db_storage')
if (fs.existsSync(wxidDbStorage)) {
dbStoragePath = wxidDbStorage
} else {
// 尝试查找匹配的账号目录
try {
const entries = fs.readdirSync(dbPath, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dirName = entry.name.toLowerCase()
const wxidLower = myWxid.toLowerCase()
// 精确匹配或前缀匹配
if (dirName === wxidLower || dirName.startsWith(wxidLower + '_')) {
const candidate = path.join(dbPath, entry.name, 'db_storage')
if (fs.existsSync(candidate)) {
dbStoragePath = candidate
break
}
}
}
} catch (e) {
// 忽略错误
}
}
}
}
if (!dbStoragePath || !fs.existsSync(dbStoragePath)) {
console.warn(`[DataManagement] db_storage 目录不存在 (dbPath: ${dbPath}),跳过文件监听`)
return
}
// 使用防抖,避免频繁触发
let debounceTimer: NodeJS.Timeout | null = null
this.dbWatcher = fs.watch(dbStoragePath, { recursive: true }, async (eventType, filename) => {
if (!filename || this.isUpdating) return
// 只监听 .db 文件
if (!filename.toLowerCase().endsWith('.db')) return
// 防抖:500ms 内的多次变化只触发一次
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(async () => {
// console.log(`[DataManagement] 检测到数据库文件变化: ${filename}`)
// 检查更新频率限制(最多每5秒更新一次)
const now = Date.now()
const timeSinceLastUpdate = now - this.lastUpdateTime
const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒 (配合 DLL 极速解密)
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
// 如果距离上次更新不足5秒,延迟到满足间隔
const delay = MIN_UPDATE_INTERVAL - timeSinceLastUpdate
// console.log(`[DataManagement] 更新过于频繁,延迟 ${delay}ms 后执行`)
setTimeout(() => {
this.triggerUpdate()
}, delay)
return
}
// 检查更新队列长度,避免堆积过多
if (this.pendingUpdateCount > 3) {
console.warn(`[DataManagement] 更新队列过长(${this.pendingUpdateCount}),跳过本次更新请求`)
return
}
// 等待文件写入完成(微信写入数据库可能需要一些时间)
// 等待文件写入完成(微信写入数据库可能需要一些时间)
// 延迟1秒,确保文件完全写入完成
await new Promise(resolve => setTimeout(resolve, 1000))
// 触发更新
this.triggerUpdate()
}, 300) // 延迟从 500ms 减少到 300ms
})
} catch (e) {
console.error('[DataManagement] 启动文件监听失败:', e)
}
}
/**
* 触发更新(带频率限制和队列管理)
*/
private triggerUpdate(): void {
// 如果正在更新,增加待处理计数
if (this.isUpdating) {
this.pendingUpdateCount++
console.log(`[DataManagement] 更新进行中,待处理请求数: ${this.pendingUpdateCount}`)
return
}
// 检查更新频率限制
const now = Date.now()
const timeSinceLastUpdate = now - this.lastUpdateTime
const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
// 延迟到满足间隔
const delay = MIN_UPDATE_INTERVAL - timeSinceLastUpdate
setTimeout(() => {
this.triggerUpdate()
}, delay)
return
}
// 通知监听器触发更新
this.updateListeners.forEach(listener => listener(true))
}
/**
* 添加更新监听器
*/
onUpdateAvailable(listener: (hasUpdate: boolean) => void): () => void {
this.updateListeners.add(listener)
return () => {
this.updateListeners.delete(listener)
}
}
/**
* 自动执行增量更新(如果检测到更新)
* @param silent 是否静默更新(不显示进度)
*/
async autoIncrementalUpdate(silent: boolean = false): Promise<{ success: boolean; updated: boolean; error?: string }> {
if (this.isUpdating) {
// 如果正在更新,返回待处理状态
this.pendingUpdateCount++
return { success: false, updated: false, error: '正在更新中,请稍候' }
}
// 检查更新频率限制
const now = Date.now()
const timeSinceLastUpdate = now - this.lastUpdateTime
const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔减少到 1 秒
if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) {
const remainingTime = MIN_UPDATE_INTERVAL - timeSinceLastUpdate
return { success: false, updated: false, error: `更新过于频繁,请 ${Math.ceil(remainingTime / 1000)} 秒后重试` }
}
const checkResult = await this.checkForUpdates()
if (!checkResult.hasUpdate) {
return { success: true, updated: false }
}
const time = new Date().toLocaleTimeString()
console.log(`[${time}] [自动更新] 检测到数据库更新, 共有 ${checkResult.updateCount} 个文件需要动态同步...`)
this.isUpdating = true
this.lastUpdateTime = now
const startTime = now
try {
// 检查聊天窗口是否打开(如果打开,可能需要用户手动刷新)
// 但为了自动更新,我们允许在聊天窗口打开时也更新
// 因为 chatService.close() 会关闭连接,更新后需要重新连接
// 设置更新超时(最多30秒)
const updatePromise = this.incrementalUpdate(silent)
const timeoutPromise = new Promise<{ success: boolean; successCount?: number; failCount?: number; error?: string }>((resolve) => {
setTimeout(() => {
resolve({ success: false, error: '更新超时(超过30秒)' })
}, 30000)
})
const result = await Promise.race([updatePromise, timeoutPromise])
const updateDuration = Date.now() - startTime
this.isUpdating = false
this.pendingUpdateCount = 0 // 重置待处理计数
if (result.success) {
// 通知监听器更新完成
// const time = new Date().toLocaleTimeString()
// console.log(`[${time}] [自动更新] 增量同步完成, 成功更新 ${result.successCount} 个文件`) // 减少日志
this.updateListeners.forEach(listener => listener(false))
return { success: true, updated: result.successCount! > 0 }
} else {
const time = new Date().toLocaleTimeString()
console.error(`[${time}] [自动更新] 同步进程失败: ${result.error}`)
return { success: false, updated: false, error: result.error }
}
} catch (e) {
this.isUpdating = false
this.pendingUpdateCount = 0
const time = new Date().toLocaleTimeString()
console.error(`[${time}] [自动更新] 发生严重异常:`, e)
return { success: false, updated: false, error: String(e) }
}
}
}
export const dataManagementService = new DataManagementService()