Files
2026-09-28 21:16:43 +08:00

1146 lines
42 KiB
TypeScript

import { BrowserWindow, app } from 'electron'
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'path'
import { tmpdir } from 'os'
import * as tar from 'tar'
import { ConfigService } from './config'
import { WcdbService } from './wcdbService'
import { expandHomePath } from '../utils/pathUtils'
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
type BackupResourceKind = 'image' | 'video' | 'file'
const TEMP_MARKER = '.weflow-backup-temp'
const TEMP_TTL_MS = 24 * 60 * 60 * 1000
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: {
accountId: string
dbRoot: string
accountDirName?: 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
}
function normalizeArchivePath(value: string): string {
return String(value || '').replace(/\\/g, '/')
}
export class BackupService {
private configService = new ConfigService()
// Backup inspection/import must never repoint the application's shared WCDB
// worker to the account recorded in an archive.
private wcdbService = new WcdbService()
private cleanedTempDirs = false
private activeDatabaseOperation = false
private wcdbRuntime?: { resourcesPath: string; userDataPath: string; logEnabled: boolean }
configureRuntime(resourcesPath: string, userDataPath: string, logEnabled: boolean): void {
this.wcdbRuntime = { resourcesPath, userDataPath, logEnabled }
}
async shutdown(): Promise<void> {
await this.wcdbService.shutdown()
}
private cleanupStaleTempDirs(): void {
if (this.cleanedTempDirs) return
this.cleanedTempDirs = true
const root = tmpdir()
const now = Date.now()
try {
for (const entry of readdirSync(root)) {
if (!entry.startsWith('weflow-backup-')) continue
const dir = join(root, entry)
const marker = join(dir, TEMP_MARKER)
try {
const stat = statSync(dir)
if (!stat.isDirectory()) continue
if (!existsSync(marker)) continue
const age = now - stat.mtimeMs
if (age < TEMP_TTL_MS) continue
rmSync(dir, { recursive: true, force: true })
} catch {}
}
} catch {}
}
private async createTempDir(prefix: string): Promise<string> {
this.cleanupStaleTempDirs()
const dir = await mkdtemp(join(tmpdir(), prefix))
await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8')
return dir
}
private buildAccountIdCandidates(accountId: string): string[] {
const accountIdCandidates = Array.from(new Set([
String(accountId || '').trim(),
this.cleanAccountDirName(accountId)
].filter(Boolean)))
return accountIdCandidates
}
private isCurrentAccountDir(accountDir: string, accountIdCandidates: string[]): boolean {
const accountName = basename(accountDir).toLowerCase()
return accountIdCandidates
.map(item => item.toLowerCase())
.some(accountId => accountName === accountId || accountName.startsWith(`${accountId}_`))
}
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, accountIdCandidates: 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, accountIdCandidates) && existsSync(current)) {
return current
}
}
const parent = dirname(current)
if (!parent || parent === current) break
current = parent
}
return null
}
private resolveCurrentAccountDbStorageFromRoot(rootPath: string, accountIdCandidates: string[]): string | null {
if (!rootPath || !existsSync(rootPath)) return null
for (const candidateAccountId of accountIdCandidates) {
const viaAccountId = join(rootPath, candidateAccountId, 'db_storage')
if (existsSync(viaAccountId)) return viaAccountId
}
try {
const entries = readdirSync(rootPath)
const loweredAccountIds = accountIdCandidates.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 (!loweredAccountIds.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, accountId: string): string | null {
const normalized = this.normalizeExistingPath(dbPath)
if (!normalized) return null
const accountIdCandidates = this.buildAccountIdCandidates(accountId)
const ancestor = this.resolveAncestorDbStorage(normalized, accountIdCandidates)
if (ancestor) return ancestor
const direct = join(normalized, 'db_storage')
if (existsSync(direct) && this.isCurrentAccountDir(normalized, accountIdCandidates)) return direct
const roots = Array.from(new Set([
normalized
]))
for (const root of roots) {
const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, accountIdCandidates)
if (dbStorage) return dbStorage
}
return null
}
private resolveAccountDir(dbPath: string, accountId: string): string | null {
const dbStorage = this.resolveDbStoragePath(dbPath, accountId)
return dbStorage ? dirname(dbStorage) : null
}
private cleanAccountDirName(accountId: string): string {
const trimmed = String(accountId || '').trim()
if (trimmed.toLowerCase().startsWith('accountId_')) {
const match = trimmed.match(/^(accountId_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
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 = normalizeArchivePath(archivePath)
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 resolveStagingPath(stagingDir: string, archivePath: string): string | null {
return this.resolveExtractedPath(stagingDir, archivePath)
}
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
const normalized = normalizeArchivePath(relativePath)
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 writeTarEntryToFile(entry: any, outputPath: string): Promise<void> {
mkdirSync(dirname(outputPath), { recursive: true })
await new Promise<void>((resolvePromise, rejectPromise) => {
const out = createWriteStream(outputPath)
const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error)))
out.on('finish', resolvePromise)
out.on('error', fail)
entry.on('error', fail)
entry.pipe(out)
})
}
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(
accountIdOverride?: string,
exactAccountDirName?: string
): Promise<{ success: boolean; accountId?: string; accountDirName?: string; dbPath?: string; dbStorage?: string; error?: string }> {
if (this.wcdbRuntime) {
this.wcdbService.setPaths(this.wcdbRuntime.resourcesPath, this.wcdbRuntime.userDataPath)
this.wcdbService.setLibPath(this.configService.get('wcdbLibPath') || '')
this.wcdbService.setLogEnabled(this.wcdbRuntime.logEnabled)
}
const configuredAccountId = String(this.configService.getMyAccountIdCleaned() || '').trim()
const accountId = String(accountIdOverride || configuredAccountId || '').trim()
const dbPath = String(this.configService.get('dbPath') || '').trim()
const decryptKey = String(this.configService.get('decryptKey') || '').trim()
if (!accountId || !dbPath) return { success: false, error: '请先配置数据库路径和账号' }
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
// 使用 ConfigService 统一解析账号目录
const safeExactName = String(exactAccountDirName || '').trim()
const exactCandidate = safeExactName && basename(safeExactName) === safeExactName
? join(dbPath, safeExactName)
: ''
const exactBelongsToAccountId = Boolean(exactCandidate && this.isCurrentAccountDir(exactCandidate, this.buildAccountIdCandidates(accountId)))
const accountDir = exactBelongsToAccountId && existsSync(join(exactCandidate, 'db_storage'))
? exactCandidate
: this.configService.getAccountDir(dbPath, accountId)
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${accountId}` }
const dbStorage = join(accountDir, 'db_storage')
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
const accountDirName = basename(accountDir)
const opened = await withTimeout(
this.wcdbService.open(accountDir, decryptKey),
15000,
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
)
if (!opened) {
const detail = await this.wcdbService.getLastInitError().catch(() => null)
return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` }
}
return { success: true, accountId, accountDirName, dbPath, dbStorage }
}
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') 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'
if (kind === 'hardlink') return 'hardlink/hardlink.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')
])
}
if (kind === 'hardlink') {
return this.findFirstExisting([
join(dbStorage, 'hardlink', 'hardlink.db'),
join(dbStorage, 'hardlink.db'),
join(dirname(dbStorage), 'hardlink.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', 'hardlink'] as const) {
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
result.push({
id: kind,
kind,
dbPath,
relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind
})
}
const messageDbs = await this.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 this.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: { accountId: string; dbStorage: string },
stagingDir: string,
manifest: BackupManifest
): Promise<void> {
const accountDir = dirname(connected.dbStorage)
const imagesDir = join(stagingDir, 'resources', 'images')
const imagePaths = await this.listChatImageDatFiles(accountDir)
if (imagePaths.length === 0) return
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 archivePath = toArchivePath(join('resources', 'images', relativeTarget))
const outputPath = join(stagingDir, archivePath)
await this.stagePlainResource(sourcePath, outputPath)
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
const stat = statSync(sourcePath)
resources.push({
kind: 'image',
id: relativeTarget,
md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined,
sourceFileName: basename(sourcePath),
archivePath,
targetRelativePath: relativeTarget,
size: stat.size
})
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 }> {
if (this.activeDatabaseOperation) return { success: false, error: '已有备份或恢复任务正在运行' }
this.activeDatabaseOperation = true
let stagingDir = ''
try {
emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' })
const connected = await this.ensureConnected()
if (!connected.success || !connected.accountId || !connected.dbPath || !connected.dbStorage) {
return { success: false, error: connected.error || '数据库未连接' }
}
stagingDir = await this.createTempDir('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: {
accountId: connected.accountId,
dbRoot: connected.dbPath,
accountDirName: connected.accountDirName
},
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 this.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 this.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 this.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(
{ accountId: connected.accountId, 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 {}
}
this.activeDatabaseOperation = false
await this.wcdbService.shutdown().catch(() => {})
}
}
async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> {
let extractDir = ''
try {
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
extractDir = await this.createTempDir('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) {
emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' })
return { success: false, error: '不支持的备份包格式' }
}
emitBackupProgress({ phase: 'done', message: '备份包已读取' })
return { success: true, manifest }
} catch (e) {
emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) })
return { success: false, error: e instanceof Error ? e.message : String(e) }
} finally {
if (extractDir) {
try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
}
}
}
private async streamRestoreArchive(
archivePath: string,
extractDir: string,
manifest: BackupManifest,
connected: { dbStorage: string; accountId?: string },
startCurrent: number,
total: number
): Promise<{ current: number; skipped: number }> {
const snapshotPaths = new Set<string>()
for (const db of manifest.databases || []) {
for (const table of db.tables || []) {
const path = normalizeArchivePath(table.snapshotPath)
if (path) snapshotPaths.add(path)
}
}
const imageByPath = new Map<string, BackupResourceEntry>()
for (const image of manifest.resources?.images || []) {
const path = normalizeArchivePath(image.archivePath)
if (path) imageByPath.set(path, image)
}
const plainByPath = new Map<string, BackupResourceEntry>()
for (const resource of [
...(manifest.resources?.videos || []),
...(manifest.resources?.files || [])
]) {
const path = normalizeArchivePath(resource.archivePath)
if (path) plainByPath.set(path, resource)
}
const accountDir = dirname(connected.dbStorage)
let current = startCurrent
let skipped = 0
const pending: Promise<void>[] = []
const emitRestoreProgress = createThrottledProgressEmitter(160)
await tar.t({
file: archivePath,
onReadEntry: (entry: any) => {
const entryPath = normalizeArchivePath(entry.path)
if (snapshotPaths.has(entryPath)) {
const outputPath = this.resolveStagingPath(extractDir, entryPath)
if (!outputPath) {
entry.resume()
return
}
pending.push(this.writeTarEntryToFile(entry, outputPath))
return
}
const image = imageByPath.get(entryPath)
if (image) {
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
if (!targetPath) {
skipped += 1
entry.resume()
return
}
current += 1
emitRestoreProgress({
phase: 'restoring',
message: '正在写回图片资源',
current,
total,
detail: image.md5 || image.targetRelativePath
})
if (existsSync(targetPath)) {
skipped += 1
entry.resume()
return
}
pending.push(this.writeTarEntryToFile(entry, targetPath))
return
}
const resource = plainByPath.get(entryPath)
if (resource) {
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
current += 1
emitRestoreProgress({
phase: 'restoring',
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
current,
total,
detail: resource.targetRelativePath
})
if (!targetPath || existsSync(targetPath)) {
skipped += 1
entry.resume()
return
}
pending.push(this.writeTarEntryToFile(entry, targetPath))
return
}
entry.resume()
}
} as any)
await Promise.all(pending)
return { current, skipped }
}
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
if (this.activeDatabaseOperation) return { success: false, error: '已有备份或恢复任务正在运行' }
this.activeDatabaseOperation = true
let extractDir = ''
try {
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' })
extractDir = await this.createTempDir('weflow-backup-restore-')
await tar.x({
file: archivePath,
cwd: extractDir,
filter: (entryPath: string) => normalizeArchivePath(entryPath) === '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: '不支持的备份包格式' }
}
const rawTargetAccountId = String(manifest.source?.accountId || '').trim()
const legacyAbsoluteAccountDir = rawTargetAccountId && (isAbsolute(rawTargetAccountId) || win32.isAbsolute(rawTargetAccountId) || posix.isAbsolute(rawTargetAccountId))
? (win32.isAbsolute(rawTargetAccountId) ? win32.basename(rawTargetAccountId) : posix.basename(rawTargetAccountId))
: ''
const targetAccountId = legacyAbsoluteAccountDir || rawTargetAccountId
const targetAccountDirName = String(manifest.source?.accountDirName || legacyAbsoluteAccountDir || '').trim()
if (!targetAccountId) return { success: false, error: '备份包缺少来源账号 accountId,无法定位目标账号目录' }
const configuredAccountId = String(this.configService.getMyAccountIdCleaned() || '').trim()
const configuredDbPath = String(this.configService.get('dbPath') || '').trim()
const configuredAccountDir = configuredAccountId && configuredDbPath
? this.configService.getAccountDir(configuredDbPath, configuredAccountId)
: null
if (!configuredAccountId || !configuredAccountDir) {
return { success: false, error: '请先切换并连接备份所属的账号,再执行恢复' }
}
if (this.cleanAccountDirName(targetAccountId).toLowerCase() !== this.cleanAccountDirName(configuredAccountId).toLowerCase()) {
return { success: false, error: '备份所属账号与当前配置账号不一致,已拒绝跨账号写入' }
}
const configuredAccountDirName = basename(configuredAccountDir)
if (targetAccountDirName && targetAccountDirName.toLowerCase() !== configuredAccountDirName.toLowerCase()) {
return { success: false, error: '备份所属账号目录与当前实际账号目录不一致,已拒绝恢复' }
}
emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetAccountId })
const connected = await this.ensureConnected(configuredAccountId, configuredAccountDirName)
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
if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) {
emitBackupProgress({
phase: 'inspecting',
message: '正在按需读取备份包',
current: 0,
total: totalRestoreJobs,
detail: archivePath
})
const streamed = await this.streamRestoreArchive(
archivePath,
extractDir,
manifest,
{ dbStorage: connected.dbStorage, accountId: connected.accountId },
0,
totalRestoreJobs
)
current = streamed.current
skipped += streamed.skipped
}
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 this.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()
}
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 {}
}
this.activeDatabaseOperation = false
await this.wcdbService.shutdown().catch(() => {})
}
}
}
export const backupService = new BackupService()