mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-25 07:26:47 +00:00
Merge branch 'hicccc77:dev' into dev
This commit is contained in:
@@ -33,6 +33,7 @@ import { messagePushService } from './services/messagePushService'
|
||||
import { insightService } from './services/insightService'
|
||||
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
|
||||
import { bizService } from './services/bizService'
|
||||
import { backupService } from './services/backupService'
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -2178,6 +2179,18 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('backup:create', async (_, payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => {
|
||||
return backupService.createBackup(payload.outputPath, payload.options)
|
||||
})
|
||||
|
||||
ipcMain.handle('backup:inspect', async (_, payload: { archivePath: string }) => {
|
||||
return backupService.inspectBackup(payload.archivePath)
|
||||
})
|
||||
|
||||
ipcMain.handle('backup:restore', async (_, payload: { archivePath: string }) => {
|
||||
return backupService.restoreBackup(payload.archivePath)
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 聊天相关
|
||||
@@ -3996,4 +4009,3 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +154,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
},
|
||||
|
||||
backup: {
|
||||
create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload),
|
||||
inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload),
|
||||
restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload),
|
||||
onProgress: (callback: (progress: any) => void) => {
|
||||
const listener = (_: unknown, progress: any) => callback(progress)
|
||||
ipcRenderer.on('backup:progress', listener)
|
||||
return () => ipcRenderer.removeListener('backup:progress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// 密钥获取
|
||||
key: {
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
|
||||
986
electron/services/backupService.ts
Normal file
986
electron/services/backupService.ts
Normal file
@@ -0,0 +1,986 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
|
||||
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import * as tar from 'tar'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
import { decryptDatViaNative, encryptDatViaNative } from './nativeImageDecrypt'
|
||||
|
||||
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
|
||||
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
|
||||
type BackupResourceKind = 'image' | 'video' | 'file'
|
||||
|
||||
export interface BackupOptions {
|
||||
includeImages?: boolean
|
||||
includeVideos?: boolean
|
||||
includeFiles?: boolean
|
||||
}
|
||||
|
||||
interface BackupDbEntry {
|
||||
id: string
|
||||
kind: BackupDbKind
|
||||
dbPath: string
|
||||
relativePath: string
|
||||
tables: BackupTableEntry[]
|
||||
}
|
||||
|
||||
interface BackupTableEntry {
|
||||
name: string
|
||||
snapshotPath: string
|
||||
rows: number
|
||||
columns: number
|
||||
schemaSql?: string
|
||||
}
|
||||
|
||||
interface BackupResourceEntry {
|
||||
kind: BackupResourceKind
|
||||
id: string
|
||||
md5?: string
|
||||
sessionId?: string
|
||||
createTime?: number
|
||||
sourceFileName?: string
|
||||
archivePath: string
|
||||
targetRelativePath: string
|
||||
ext?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface BackupManifest {
|
||||
version: 1
|
||||
type: 'weflow-db-snapshots'
|
||||
createdAt: string
|
||||
appVersion: string
|
||||
source: {
|
||||
wxid: string
|
||||
dbRoot: string
|
||||
}
|
||||
databases: BackupDbEntry[]
|
||||
options?: BackupOptions
|
||||
resources?: {
|
||||
images?: BackupResourceEntry[]
|
||||
videos?: BackupResourceEntry[]
|
||||
files?: BackupResourceEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupProgress {
|
||||
phase: BackupPhase
|
||||
message: string
|
||||
current?: number
|
||||
total?: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function emitBackupProgress(progress: BackupProgress): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('backup:progress', progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeName(value: string): string {
|
||||
return encodeURIComponent(value || 'unnamed').replace(/%/g, '_')
|
||||
}
|
||||
|
||||
function toArchivePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
async function withTimeout<T>(task: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
task,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms = 0): Promise<void> {
|
||||
return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
|
||||
}
|
||||
|
||||
function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void {
|
||||
let lastEmitAt = 0
|
||||
return (progress: BackupProgress, force = false) => {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastEmitAt < minIntervalMs) return
|
||||
lastEmitAt = now
|
||||
emitBackupProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (index >= items.length) return
|
||||
await worker(items[index], index)
|
||||
if (index % 50 === 0) await delay()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function hasResourceOptions(options: BackupOptions): boolean {
|
||||
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
private configService = new ConfigService()
|
||||
|
||||
private buildWxidCandidates(wxid: string): string[] {
|
||||
const wxidCandidates = Array.from(new Set([
|
||||
String(wxid || '').trim(),
|
||||
this.cleanAccountDirName(wxid)
|
||||
].filter(Boolean)))
|
||||
return wxidCandidates
|
||||
}
|
||||
|
||||
private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean {
|
||||
const accountName = basename(accountDir).toLowerCase()
|
||||
return wxidCandidates
|
||||
.map(item => item.toLowerCase())
|
||||
.some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`))
|
||||
}
|
||||
|
||||
private normalizeExistingPath(inputPath: string): string {
|
||||
const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '')
|
||||
if (!expanded) return expanded
|
||||
try {
|
||||
if (existsSync(expanded) && statSync(expanded).isFile()) {
|
||||
return dirname(expanded)
|
||||
}
|
||||
} catch {}
|
||||
return expanded
|
||||
}
|
||||
|
||||
private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null {
|
||||
let current = normalized
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (!current) break
|
||||
if (basename(current).toLowerCase() === 'db_storage') {
|
||||
const accountDir = dirname(current)
|
||||
if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (!parent || parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null {
|
||||
if (!rootPath || !existsSync(rootPath)) return null
|
||||
|
||||
for (const candidateWxid of wxidCandidates) {
|
||||
const viaWxid = join(rootPath, candidateWxid, 'db_storage')
|
||||
if (existsSync(viaWxid)) return viaWxid
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(rootPath)
|
||||
const loweredWxids = wxidCandidates.map(item => item.toLowerCase())
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
try {
|
||||
if (!statSync(entryPath).isDirectory()) continue
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue
|
||||
const candidate = join(entryPath, 'db_storage')
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDbStoragePath(dbPath: string, wxid: string): string | null {
|
||||
const normalized = this.normalizeExistingPath(dbPath)
|
||||
if (!normalized) return null
|
||||
|
||||
const wxidCandidates = this.buildWxidCandidates(wxid)
|
||||
const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates)
|
||||
if (ancestor) return ancestor
|
||||
|
||||
const direct = join(normalized, 'db_storage')
|
||||
if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct
|
||||
|
||||
const roots = Array.from(new Set([
|
||||
normalized,
|
||||
join(normalized, 'WeChat Files'),
|
||||
join(normalized, 'xwechat_files')
|
||||
]))
|
||||
for (const root of roots) {
|
||||
const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates)
|
||||
if (dbStorage) return dbStorage
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const dbStorage = this.resolveDbStoragePath(dbPath, wxid)
|
||||
return dbStorage ? dirname(dbStorage) : null
|
||||
}
|
||||
|
||||
private cleanAccountDirName(wxid: string): string {
|
||||
const trimmed = String(wxid || '').trim()
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private parseImageXorKey(value: unknown): number {
|
||||
if (typeof value === 'number') return value
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return Number.NaN
|
||||
return text.toLowerCase().startsWith('0x') ? parseInt(text, 16) : parseInt(text, 10)
|
||||
}
|
||||
|
||||
private getImageKeysForWxid(wxid: string): { xorKey: number; aesKey?: string } | null {
|
||||
const wxidConfigs = this.configService.get('wxidConfigs') || {}
|
||||
const candidates = this.buildWxidCandidates(wxid)
|
||||
const matchedKey = Object.keys(wxidConfigs).find((key) => {
|
||||
const cleanKey = this.cleanAccountDirName(key).toLowerCase()
|
||||
return candidates.some(candidate => cleanKey === candidate.toLowerCase())
|
||||
})
|
||||
const cfg = matchedKey ? wxidConfigs[matchedKey] : undefined
|
||||
const xorKey = this.parseImageXorKey(cfg?.imageXorKey ?? this.configService.get('imageXorKey'))
|
||||
if (!Number.isFinite(xorKey)) return null
|
||||
const aesKey = String(cfg?.imageAesKey ?? this.configService.get('imageAesKey') ?? '').trim()
|
||||
return { xorKey, aesKey: aesKey || undefined }
|
||||
}
|
||||
|
||||
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
|
||||
const dir = join(root, rel)
|
||||
const files: string[] = []
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const entryRel = rel ? join(rel, entry) : entry
|
||||
const entryPath = join(root, entryRel)
|
||||
try {
|
||||
const stat = statSync(entryPath)
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await this.listFilesForArchive(root, entryRel, state))
|
||||
} else if (stat.isFile()) {
|
||||
files.push(toArchivePath(entryRel))
|
||||
}
|
||||
state.visited += 1
|
||||
if (state.visited % 200 === 0) await delay()
|
||||
} catch {}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
|
||||
const normalized = String(archivePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(extractDir)
|
||||
const target = resolve(join(extractDir, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
|
||||
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(accountDir)
|
||||
const target = resolve(join(accountDir, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null {
|
||||
const rel = toArchivePath(relative(accountDir, filePath))
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null
|
||||
return rel
|
||||
}
|
||||
|
||||
private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
if (!existsSync(root)) return files
|
||||
try {
|
||||
for (const entry of readdirSync(root)) {
|
||||
const fullPath = join(root, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await this.listFilesUnderDir(fullPath, state))
|
||||
} else if (stat.isFile()) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
state.visited += 1
|
||||
if (state.visited % 300 === 0) await delay()
|
||||
}
|
||||
} catch {}
|
||||
return files
|
||||
}
|
||||
|
||||
private async stagePlainResource(sourcePath: string, outputPath: string): Promise<void> {
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
await link(sourcePath, outputPath)
|
||||
} catch {
|
||||
await copyFile(sourcePath, outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
|
||||
const attachRoot = join(accountDir, 'msg', 'attach')
|
||||
const result: string[] = []
|
||||
if (!existsSync(attachRoot)) return result
|
||||
|
||||
const scanImgDir = async (imgDir: string): Promise<void> => {
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = readdirSync(imgDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(imgDir, entry)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) {
|
||||
result.push(fullPath)
|
||||
} else if (stat.isDirectory()) {
|
||||
let nestedEntries: string[] = []
|
||||
try {
|
||||
nestedEntries = readdirSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const nestedEntry of nestedEntries) {
|
||||
const nestedPath = join(fullPath, nestedEntry)
|
||||
try {
|
||||
if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) {
|
||||
result.push(nestedPath)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (result.length > 0 && result.length % 500 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }> = []
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const child = join(dir, entry.name)
|
||||
if (entry.name.toLowerCase() === 'img') {
|
||||
await scanImgDir(child)
|
||||
} else {
|
||||
await walk(child)
|
||||
}
|
||||
if (result.length > 0 && result.length % 500 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
await walk(attachRoot)
|
||||
return Array.from(new Set(result))
|
||||
}
|
||||
|
||||
private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> {
|
||||
const configuredWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const wxid = String(wxidOverride || configuredWxid || '').trim()
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const decryptKey = String(this.configService.get('decryptKey') || '').trim()
|
||||
if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
|
||||
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
|
||||
const dbStorage = join(accountDir, 'db_storage')
|
||||
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
||||
|
||||
const accountDirName = basename(accountDir)
|
||||
const opened = await withTimeout(
|
||||
wcdbService.open(dbPath, decryptKey, accountDirName),
|
||||
15000,
|
||||
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
|
||||
)
|
||||
if (!opened) {
|
||||
const detail = await wcdbService.getLastInitError().catch(() => null)
|
||||
return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` }
|
||||
}
|
||||
|
||||
return { success: true, wxid: accountDirName, dbPath, dbStorage }
|
||||
}
|
||||
|
||||
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
|
||||
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns') return kind
|
||||
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
|
||||
}
|
||||
|
||||
private toDbRelativePath(dbStorage: string, dbPath: string): string {
|
||||
const rel = toArchivePath(relative(dbStorage, dbPath))
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath)
|
||||
return rel
|
||||
}
|
||||
|
||||
private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null {
|
||||
const normalized = String(relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
|
||||
const root = resolve(dbStorage)
|
||||
const target = resolve(join(dbStorage, normalized))
|
||||
if (target !== root && !target.startsWith(`${root}${sep}`)) return null
|
||||
return target
|
||||
}
|
||||
|
||||
private defaultRelativeDbPath(kind: BackupDbKind): string | null {
|
||||
if (kind === 'session') return 'session/session.db'
|
||||
if (kind === 'contact') return 'contact/contact.db'
|
||||
if (kind === 'emoticon') return 'emoticon/emoticon.db'
|
||||
if (kind === 'sns') return 'sns/sns.db'
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null {
|
||||
const normalized = String(db.relativePath || '').replace(/\\/g, '/')
|
||||
const legacyFixedPath = this.defaultRelativeDbPath(db.kind)
|
||||
if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) {
|
||||
return this.resolveTargetDbPath(dbStorage, legacyFixedPath)
|
||||
}
|
||||
return this.resolveTargetDbPath(dbStorage, db.relativePath)
|
||||
}
|
||||
|
||||
private findFirstExisting(paths: string[]): string {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (existsSync(path) && statSync(path).isFile()) return path
|
||||
} catch {}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string {
|
||||
if (kind === 'session') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'session', 'session.db'),
|
||||
join(dbStorage, 'Session', 'session.db'),
|
||||
join(dbStorage, 'session.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'contact') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'Contact', 'contact.db'),
|
||||
join(dbStorage, 'Contact', 'Contact.db'),
|
||||
join(dbStorage, 'contact', 'contact.db'),
|
||||
join(dbStorage, 'session', 'contact.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'emoticon') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'emoticon', 'emoticon.db'),
|
||||
join(dbStorage, 'emotion', 'emoticon.db')
|
||||
])
|
||||
}
|
||||
if (kind === 'sns') {
|
||||
return this.findFirstExisting([
|
||||
join(dbStorage, 'sns', 'sns.db'),
|
||||
join(dirname(dbStorage), 'sns', 'sns.db')
|
||||
])
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
|
||||
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
|
||||
for (const kind of ['session', 'contact', 'emoticon', 'sns'] as const) {
|
||||
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
|
||||
result.push({
|
||||
id: kind,
|
||||
kind,
|
||||
dbPath,
|
||||
relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind
|
||||
})
|
||||
}
|
||||
|
||||
const messageDbs = await wcdbService.listMessageDbs()
|
||||
if (messageDbs.success && Array.isArray(messageDbs.data)) {
|
||||
messageDbs.data.forEach((dbPath, index) => {
|
||||
result.push({
|
||||
id: this.buildDbId('message', index, dbPath),
|
||||
kind: 'message',
|
||||
dbPath,
|
||||
relativePath: this.toDbRelativePath(dbStorage, dbPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mediaDbs = await wcdbService.listMediaDbs()
|
||||
if (mediaDbs.success && Array.isArray(mediaDbs.data)) {
|
||||
mediaDbs.data.forEach((dbPath, index) => {
|
||||
result.push({
|
||||
id: this.buildDbId('media', index, dbPath),
|
||||
kind: 'media',
|
||||
dbPath,
|
||||
relativePath: this.toDbRelativePath(dbStorage, dbPath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async collectImageResources(
|
||||
connected: { wxid: string; dbStorage: string },
|
||||
stagingDir: string,
|
||||
manifest: BackupManifest
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const keys = this.getImageKeysForWxid(connected.wxid)
|
||||
const imagesDir = join(stagingDir, 'resources', 'images')
|
||||
const imagePaths = await this.listChatImageDatFiles(accountDir)
|
||||
if (imagePaths.length === 0) return
|
||||
if (!keys) throw new Error('存在图片资源,但未配置图片解密密钥')
|
||||
|
||||
mkdirSync(imagesDir, { recursive: true })
|
||||
const resources: BackupResourceEntry[] = []
|
||||
const emitImageProgress = createThrottledProgressEmitter(160)
|
||||
for (let index = 0; index < imagePaths.length; index += 1) {
|
||||
const sourcePath = imagePaths[index]
|
||||
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
||||
if (!relativeTarget) continue
|
||||
emitImageProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在解密图片资源',
|
||||
current: index + 1,
|
||||
total: imagePaths.length,
|
||||
detail: relativeTarget
|
||||
})
|
||||
const decrypted = decryptDatViaNative(sourcePath, keys.xorKey, keys.aesKey)
|
||||
if (!decrypted) continue
|
||||
const archivePath = toArchivePath(join('resources', 'images', `${relativeTarget}${decrypted.ext || '.bin'}`))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, decrypted.data)
|
||||
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
|
||||
resources.push({
|
||||
kind: 'image',
|
||||
id: relativeTarget,
|
||||
md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined,
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
ext: decrypted.ext || undefined,
|
||||
size: decrypted.data.length
|
||||
})
|
||||
if (index % 20 === 0) await delay()
|
||||
}
|
||||
|
||||
if (resources.length > 0) {
|
||||
manifest.resources = { ...(manifest.resources || {}), images: resources }
|
||||
}
|
||||
}
|
||||
|
||||
private async collectPlainResources(
|
||||
connected: { dbStorage: string },
|
||||
stagingDir: string,
|
||||
manifest: BackupManifest,
|
||||
kind: 'video' | 'file'
|
||||
): Promise<void> {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
const roots = kind === 'video'
|
||||
? [
|
||||
join(accountDir, 'msg', 'video'),
|
||||
join(accountDir, 'FileStorage', 'Video')
|
||||
]
|
||||
: [
|
||||
join(accountDir, 'FileStorage', 'File'),
|
||||
join(accountDir, 'msg', 'file')
|
||||
]
|
||||
const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root)))
|
||||
const uniqueFiles = Array.from(new Set(listed.flat()))
|
||||
if (uniqueFiles.length === 0) return
|
||||
|
||||
const resources: BackupResourceEntry[] = []
|
||||
const bucket = kind === 'video' ? 'videos' : 'files'
|
||||
const emitResourceProgress = createThrottledProgressEmitter(180)
|
||||
await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => {
|
||||
emitResourceProgress({
|
||||
phase: 'exporting',
|
||||
message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源',
|
||||
current: index + 1,
|
||||
total: uniqueFiles.length,
|
||||
detail: basename(sourcePath)
|
||||
})
|
||||
const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
|
||||
if (!relativeTarget) return
|
||||
const archivePath = toArchivePath(join('resources', bucket, relativeTarget))
|
||||
const outputPath = join(stagingDir, archivePath)
|
||||
await this.stagePlainResource(sourcePath, outputPath)
|
||||
let size = 0
|
||||
try { size = statSync(sourcePath).size } catch {}
|
||||
const entry: BackupResourceEntry = {
|
||||
kind,
|
||||
id: relativeTarget,
|
||||
sourceFileName: basename(sourcePath),
|
||||
archivePath,
|
||||
targetRelativePath: relativeTarget,
|
||||
size
|
||||
}
|
||||
resources.push(entry)
|
||||
})
|
||||
|
||||
if (resources.length > 0) {
|
||||
manifest.resources = {
|
||||
...(manifest.resources || {}),
|
||||
[bucket]: resources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> {
|
||||
let stagingDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' })
|
||||
const connected = await this.ensureConnected()
|
||||
if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) {
|
||||
return { success: false, error: connected.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
stagingDir = await mkdtemp(join(tmpdir(), 'weflow-backup-'))
|
||||
const snapshotsDir = join(stagingDir, 'snapshots')
|
||||
mkdirSync(snapshotsDir, { recursive: true })
|
||||
|
||||
const dbs = await this.collectDatabases(connected.dbStorage)
|
||||
const manifest: BackupManifest = {
|
||||
version: 1,
|
||||
type: 'weflow-db-snapshots',
|
||||
createdAt: new Date().toISOString(),
|
||||
appVersion: app.getVersion(),
|
||||
source: {
|
||||
wxid: connected.wxid,
|
||||
dbRoot: connected.dbPath
|
||||
},
|
||||
databases: [],
|
||||
options: {
|
||||
includeImages: options.includeImages === true,
|
||||
includeVideos: options.includeVideos === true,
|
||||
includeFiles: options.includeFiles === true
|
||||
}
|
||||
}
|
||||
|
||||
const tableJobs: Array<{ db: Omit<BackupDbEntry, 'tables'>; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = []
|
||||
for (let index = 0; index < dbs.length; index += 1) {
|
||||
const db = dbs[index]
|
||||
emitBackupProgress({
|
||||
phase: 'scanning',
|
||||
message: '正在扫描数据库和表',
|
||||
current: index + 1,
|
||||
total: dbs.length,
|
||||
detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}`
|
||||
})
|
||||
const tablesResult = await wcdbService.listTables(db.kind, db.dbPath)
|
||||
if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue
|
||||
const dbDir = join(snapshotsDir, db.id)
|
||||
mkdirSync(dbDir, { recursive: true })
|
||||
const entry: BackupDbEntry = { ...db, tables: [] }
|
||||
manifest.databases.push(entry)
|
||||
for (const table of tablesResult.tables) {
|
||||
const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table)
|
||||
if (!schemaResult.success || !schemaResult.schema) continue
|
||||
const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`))
|
||||
tableJobs.push({
|
||||
db,
|
||||
table,
|
||||
schemaSql: schemaResult.schema,
|
||||
snapshotPath,
|
||||
outputPath: join(stagingDir, snapshotPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let current = 0
|
||||
for (const job of tableJobs) {
|
||||
current++
|
||||
emitBackupProgress({
|
||||
phase: 'exporting',
|
||||
message: '正在导出数据库快照',
|
||||
current,
|
||||
total: tableJobs.length,
|
||||
detail: `${job.db.kind}:${job.table}`
|
||||
})
|
||||
const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath)
|
||||
if (!exported.success) {
|
||||
throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`)
|
||||
}
|
||||
const dbEntry = manifest.databases.find(item => item.id === job.db.id)
|
||||
dbEntry?.tables.push({
|
||||
name: job.table,
|
||||
snapshotPath: job.snapshotPath,
|
||||
rows: exported.rows || 0,
|
||||
columns: exported.columns || 0,
|
||||
schemaSql: job.schemaSql
|
||||
})
|
||||
}
|
||||
|
||||
if (options.includeImages === true) {
|
||||
await this.collectImageResources(
|
||||
{ wxid: connected.wxid, dbStorage: connected.dbStorage },
|
||||
stagingDir,
|
||||
manifest
|
||||
)
|
||||
}
|
||||
if (options.includeVideos === true) {
|
||||
await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video')
|
||||
}
|
||||
if (options.includeFiles === true) {
|
||||
await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file')
|
||||
}
|
||||
|
||||
await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const archiveFiles = await this.listFilesForArchive(stagingDir)
|
||||
const shouldCompress = !hasResourceOptions(options)
|
||||
let packed = 0
|
||||
const emitPackingProgress = createThrottledProgressEmitter(150)
|
||||
emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length })
|
||||
await tar.c({
|
||||
gzip: shouldCompress ? { level: 1 } : false,
|
||||
cwd: stagingDir,
|
||||
file: outputPath,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
sync: false,
|
||||
onWriteEntry: (entry: any) => {
|
||||
packed += 1
|
||||
emitPackingProgress({
|
||||
phase: 'packing',
|
||||
message: '正在写入备份包',
|
||||
current: Math.min(packed, archiveFiles.length),
|
||||
total: archiveFiles.length,
|
||||
detail: String(entry?.path || entry || '')
|
||||
})
|
||||
}
|
||||
} as any, archiveFiles)
|
||||
emitBackupProgress({
|
||||
phase: 'packing',
|
||||
message: '正在写入备份包',
|
||||
current: archiveFiles.length,
|
||||
total: archiveFiles.length
|
||||
})
|
||||
emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length })
|
||||
return { success: true, filePath: outputPath, manifest }
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
emitBackupProgress({ phase: 'failed', message: error })
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
if (stagingDir) {
|
||||
try { rmSync(stagingDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
|
||||
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-inspect-'))
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: extractDir,
|
||||
filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json'
|
||||
} as any)
|
||||
const manifestPath = join(extractDir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
return { success: true, manifest }
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
|
||||
let extractDir = ''
|
||||
try {
|
||||
emitBackupProgress({ phase: 'inspecting', message: '正在解包备份' })
|
||||
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-restore-'))
|
||||
await tar.x({ file: archivePath, cwd: extractDir })
|
||||
const manifestPath = join(extractDir, 'manifest.json')
|
||||
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
|
||||
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
|
||||
if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
|
||||
return { success: false, error: '不支持的备份包格式' }
|
||||
}
|
||||
const targetWxid = String(manifest.source?.wxid || '').trim()
|
||||
if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' }
|
||||
|
||||
emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid })
|
||||
const connected = await this.ensureConnected(targetWxid)
|
||||
if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' }
|
||||
|
||||
const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table })))
|
||||
const imageJobs = manifest.resources?.images || []
|
||||
const plainResourceJobs = [
|
||||
...(manifest.resources?.videos || []),
|
||||
...(manifest.resources?.files || [])
|
||||
]
|
||||
const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length
|
||||
let inserted = 0
|
||||
let ignored = 0
|
||||
let skipped = 0
|
||||
let current = 0
|
||||
for (const job of tableJobs) {
|
||||
current++
|
||||
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
|
||||
if (targetDbPath === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if (!job.table.schemaSql) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在通过 WCDB 写入数据库',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: `${job.db.kind}:${job.table.name}`
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath)
|
||||
if (!inputPath || !existsSync(inputPath)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetDbPath), { recursive: true })
|
||||
const restored = await wcdbService.importTableSnapshotWithSchema(
|
||||
job.db.kind,
|
||||
targetDbPath,
|
||||
job.table.name,
|
||||
inputPath,
|
||||
job.table.schemaSql
|
||||
)
|
||||
if (!restored.success) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
inserted += restored.inserted || 0
|
||||
ignored += restored.ignored || 0
|
||||
if (current % 4 === 0) await delay()
|
||||
}
|
||||
|
||||
if (imageJobs.length > 0) {
|
||||
const targetWxid = connected.wxid || String(manifest.source?.wxid || '').trim()
|
||||
const imageKeys = this.getImageKeysForWxid(targetWxid)
|
||||
if (!imageKeys) throw new Error('备份包包含图片资源,但目标账号未配置图片加密密钥')
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
for (const image of imageJobs) {
|
||||
current += 1
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: '正在加密并写回图片资源',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: image.md5 || image.targetRelativePath
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, image.archivePath)
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
|
||||
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
const encrypted = encryptDatViaNative(inputPath, imageKeys.xorKey, imageKeys.aesKey)
|
||||
if (!encrypted) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
await writeFile(targetPath, encrypted)
|
||||
if (current % 16 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
if (plainResourceJobs.length > 0) {
|
||||
const accountDir = dirname(connected.dbStorage)
|
||||
for (const resource of plainResourceJobs) {
|
||||
current += 1
|
||||
emitBackupProgress({
|
||||
phase: 'restoring',
|
||||
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
|
||||
current,
|
||||
total: totalRestoreJobs,
|
||||
detail: resource.targetRelativePath
|
||||
})
|
||||
const inputPath = this.resolveExtractedPath(extractDir, resource.archivePath)
|
||||
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
|
||||
if (!inputPath || !targetPath || !existsSync(inputPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
if (existsSync(targetPath)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
await copyFile(inputPath, targetPath)
|
||||
if (current % 30 === 0) await delay()
|
||||
}
|
||||
}
|
||||
|
||||
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
|
||||
return { success: true, inserted, ignored, skipped }
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e)
|
||||
emitBackupProgress({ phase: 'failed', message: error })
|
||||
return { success: false, error }
|
||||
} finally {
|
||||
if (extractDir) {
|
||||
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const backupService = new BackupService()
|
||||
@@ -10,6 +10,7 @@ type NativeDecryptResult = {
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string) => Buffer
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
@@ -108,3 +109,19 @@ export function decryptDatViaNative(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): Buffer | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
||||
|
||||
try {
|
||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey)
|
||||
return Buffer.isBuffer(result) ? result : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ export class WcdbCore {
|
||||
private wcdbGetSnsUsernames: any = null
|
||||
private wcdbGetSnsExportStats: any = null
|
||||
private wcdbGetMessageTableColumns: any = null
|
||||
private wcdbListTables: any = null
|
||||
private wcdbGetTableSchema: any = null
|
||||
private wcdbExportTableSnapshot: any = null
|
||||
private wcdbImportTableSnapshot: any = null
|
||||
private wcdbImportTableSnapshotWithSchema: any = null
|
||||
private wcdbGetMessageTableTimeRange: any = null
|
||||
private wcdbResolveImageHardlink: any = null
|
||||
private wcdbResolveImageHardlinkBatch: any = null
|
||||
@@ -1090,6 +1095,31 @@ export class WcdbCore {
|
||||
} catch {
|
||||
this.wcdbGetMessageTableColumns = null
|
||||
}
|
||||
try {
|
||||
this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbListTables = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetTableSchema = null
|
||||
}
|
||||
try {
|
||||
this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbExportTableSnapshot = null
|
||||
}
|
||||
try {
|
||||
this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbImportTableSnapshot = null
|
||||
}
|
||||
try {
|
||||
this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbImportTableSnapshotWithSchema = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
@@ -2902,6 +2932,96 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbListTables) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbListTables(this.handle, kind, dbPath || '', outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取表列表失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析表列表失败' }
|
||||
const tables = JSON.parse(jsonStr)
|
||||
return { success: true, tables: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` }
|
||||
return { success: true, schema: String(data?.schema || '') }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` }
|
||||
return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
|
||||
return {
|
||||
success: true,
|
||||
rows: Number(data?.rows || 0),
|
||||
inserted: Number(data?.inserted || 0),
|
||||
ignored: Number(data?.ignored || 0),
|
||||
malformed: Number(data?.malformed || 0),
|
||||
columns: Number(data?.columns || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
|
||||
return {
|
||||
success: true,
|
||||
rows: Number(data?.rows || 0),
|
||||
inserted: Number(data?.inserted || 0),
|
||||
ignored: Number(data?.ignored || 0),
|
||||
malformed: Number(data?.malformed || 0),
|
||||
columns: Number(data?.columns || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' }
|
||||
|
||||
@@ -366,6 +366,26 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||
}
|
||||
|
||||
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
|
||||
return this.callWorker('listTables', { kind, dbPath })
|
||||
}
|
||||
|
||||
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
|
||||
return this.callWorker('getTableSchema', { kind, dbPath, tableName })
|
||||
}
|
||||
|
||||
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath })
|
||||
}
|
||||
|
||||
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath })
|
||||
}
|
||||
|
||||
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql })
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||
}
|
||||
|
||||
@@ -116,6 +116,21 @@ if (parentPort) {
|
||||
case 'getMessageTableColumns':
|
||||
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'listTables':
|
||||
result = await core.listTables(payload.kind, payload.dbPath)
|
||||
break
|
||||
case 'getTableSchema':
|
||||
result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'exportTableSnapshot':
|
||||
result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath)
|
||||
break
|
||||
case 'importTableSnapshot':
|
||||
result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath)
|
||||
break
|
||||
case 'importTableSnapshotWithSchema':
|
||||
result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql)
|
||||
break
|
||||
case 'getMessageTableTimeRange':
|
||||
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user