import { randomBytes } from 'node:crypto' import { getDb, getPrimaryAdminUserId } from '../db' import { ensureBackupSchema } from './schema' export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync' | 'site_sync' export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed' export interface BackupJob { id: string userId: number | null type: BackupJobType status: BackupJobStatus progress: number total: number message: string payload: Record outputPath: string | null error: string | null createdAt: string updatedAt: string hidden: boolean finishedAt: string | null } interface BackupJobRow { id: string user_id: number | null type: BackupJobType status: BackupJobStatus progress: number total: number message: string payload_json: string output_path: string | null error: string | null created_at: string updated_at: string hidden: number finished_at: string | null } function rowToJob(row: BackupJobRow): BackupJob { const payload = (() => { try { return JSON.parse(row.payload_json) as Record } catch { return {} } })() return { id: row.id, userId: row.user_id ?? null, type: row.type, status: row.status, progress: row.progress, total: row.total, message: row.message, payload, outputPath: row.output_path, error: row.error, createdAt: row.created_at, updatedAt: row.updated_at, hidden: Number(row.hidden) === 1, finishedAt: row.finished_at } } function generateJobId(): string { const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').slice(0, 15) const suffix = randomBytes(3).toString('hex') return `${stamp}-${suffix}` } export function getRunningBackupJob(userId?: number): BackupJob | null { ensureBackupSchema() const row = getDb().prepare(` SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at FROM backup_jobs WHERE status = 'running'${userId === undefined ? '' : ' AND user_id = ?'} LIMIT 1 `).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined return row ? rowToJob(row) : null } export function getActiveBackupJob(userId?: number): BackupJob | null { ensureBackupSchema() const row = getDb().prepare(` SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at FROM backup_jobs WHERE status IN ('pending', 'running')${userId === undefined ? '' : ' AND user_id = ?'} ORDER BY created_at DESC LIMIT 1 `).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined return row ? rowToJob(row) : null } export function createBackupJob(input: { type: BackupJobType userId?: number | null total?: number message?: string payload?: Record }): BackupJob { ensureBackupSchema() const id = generateJobId() const now = new Date().toISOString() const payloadJson = JSON.stringify(input.payload ?? {}) const userId = input.userId === undefined ? getPrimaryAdminUserId() : input.userId getDb().prepare(` INSERT INTO backup_jobs (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, finished_at) VALUES (?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, ?, NULL) `).run( id, userId, input.type, input.total ?? 0, input.message ?? '', payloadJson, now, now ) return getBackupJob(id)! } export function getBackupJob(id: string, userId?: number): BackupJob | null { ensureBackupSchema() const row = getDb().prepare(` SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at FROM backup_jobs WHERE id = ?${userId === undefined ? '' : ' AND user_id = ?'} `).get(...(userId === undefined ? [id] : [id, userId])) as BackupJobRow | undefined return row ? rowToJob(row) : null } export function listBackupJobs( limit = 20, userId?: number, includeHidden = false ): BackupJob[] { return listBackupJobsPage(1, limit, userId, includeHidden).jobs } export function countBackupJobs( userId?: number, includeHidden = false ): number { ensureBackupSchema() const where = [ ...(userId === undefined ? [] : ['user_id = ?']), ...(includeHidden ? [] : ['hidden = 0']) ] const row = getDb().prepare(` SELECT COUNT(*) AS count FROM backup_jobs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} `).get(...(userId === undefined ? [] : [userId])) as { count: number } return row.count } export function listBackupJobsPage( page = 1, pageSize = 10, userId?: number, includeHidden = false ): { jobs: BackupJob[], total: number } { ensureBackupSchema() const limit = Math.min(50, Math.max(1, pageSize)) const safePage = Math.max(1, page) const offset = (safePage - 1) * limit const total = countBackupJobs(userId, includeHidden) const where = [ ...(userId === undefined ? [] : ['user_id = ?']), ...(includeHidden ? [] : ['hidden = 0']) ] const rows = getDb().prepare(` SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at FROM backup_jobs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT ? OFFSET ? `).all(...(userId === undefined ? [limit, offset] : [userId, limit, offset])) as unknown as BackupJobRow[] return { jobs: rows.map(rowToJob), total } } export function listAllBackupJobsForUser(userId: number): BackupJob[] { ensureBackupSchema() const rows = getDb().prepare(` SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at FROM backup_jobs WHERE user_id = ? ORDER BY created_at DESC `).all(userId) as unknown as BackupJobRow[] return rows.map(rowToJob) } export function deleteBackupJobsForUser(userId: number): number { ensureBackupSchema() const result = getDb().prepare('DELETE FROM backup_jobs WHERE user_id = ?').run(userId) return Number(result.changes ?? 0) } /** Capture live task history before a full restore swaps the database file. */ export function snapshotBackupJobs(limit = 50): BackupJob[] { return listBackupJobs(limit) } /** Restore task history after the database file was replaced from a backup package. */ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void { ensureBackupSchema() const db = getDb() db.exec('BEGIN') try { db.prepare('DELETE FROM backup_jobs').run() const insert = db.prepare(` INSERT INTO backup_jobs (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) for (const job of preserved) { insert.run( job.id, job.userId, job.type, job.status, job.progress, job.total, job.message, JSON.stringify(job.payload), job.outputPath, job.error, job.createdAt, job.updatedAt, job.hidden ? 1 : 0, job.finishedAt ) } db.exec('COMMIT') } catch (error) { db.exec('ROLLBACK') throw error } } /** Mark jobs left in `running` after a crash/restart so new work can start. */ export function recoverInterruptedBackupJobs(): number { ensureBackupSchema() const finishedAt = new Date().toISOString() const result = getDb().prepare(` UPDATE backup_jobs SET status = 'failed', error = '任务中断(服务重启或异常退出)', message = '任务已中断,请重试', updated_at = ?, finished_at = ? WHERE status = 'running' `).run(finishedAt, finishedAt) return Number(result.changes ?? 0) } export function updateBackupJob( id: string, patch: Partial<{ status: BackupJobStatus progress: number total: number message: string payload: Record outputPath: string | null error: string | null finishedAt: string | null }> ): void { ensureBackupSchema() const current = getBackupJob(id) if (!current) return const next = { status: patch.status ?? current.status, progress: patch.progress ?? current.progress, total: patch.total ?? current.total, message: patch.message ?? current.message, payload: patch.payload ?? current.payload, outputPath: patch.outputPath !== undefined ? patch.outputPath : current.outputPath, error: patch.error !== undefined ? patch.error : current.error, updatedAt: new Date().toISOString(), finishedAt: patch.finishedAt !== undefined ? patch.finishedAt : current.finishedAt } getDb().prepare(` UPDATE backup_jobs SET status = ?, progress = ?, total = ?, message = ?, payload_json = ?, output_path = ?, error = ?, updated_at = ?, finished_at = ? WHERE id = ? `).run( next.status, next.progress, next.total, next.message, JSON.stringify(next.payload), next.outputPath, next.error, next.updatedAt, next.finishedAt, id ) } /** Update job data without changing its last activity timestamp. */ export function updateBackupJobPayload( id: string, payload: Record ): void { ensureBackupSchema() getDb().prepare(` UPDATE backup_jobs SET payload_json = ? WHERE id = ? `).run(JSON.stringify(payload), id) } export function setBackupJobHidden( id: string, userId: number, hidden: boolean ): boolean { ensureBackupSchema() const result = getDb().prepare(` UPDATE backup_jobs SET hidden = ? WHERE id = ? AND user_id = ? `).run(hidden ? 1 : 0, id, userId) return Number(result.changes ?? 0) > 0 } export function setAllFinishedBackupJobsHidden( userId: number, hidden: boolean ): number { ensureBackupSchema() const result = getDb().prepare(` UPDATE backup_jobs SET hidden = ? WHERE user_id = ? AND status IN ('done', 'failed') `).run(hidden ? 1 : 0, userId) return Number(result.changes ?? 0) } export function markJobRunning(id: string, message?: string): void { updateBackupJob(id, { status: 'running', message: message ?? '' }) } export function markJobDone(id: string, patch?: { message?: string outputPath?: string | null payload?: Record }): void { updateBackupJob(id, { status: 'done', message: patch?.message ?? '', outputPath: patch?.outputPath, payload: patch?.payload, finishedAt: new Date().toISOString(), error: null }) } export function markJobFailed(id: string, error: string, message?: string): void { updateBackupJob(id, { status: 'failed', error, message: message ?? error, finishedAt: new Date().toISOString() }) }