Files
FileHost/server/utils/backup/jobs.ts
T
2026-10-17 14:41:32 +08:00

317 lines
9.1 KiB
TypeScript

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<string, unknown>
outputPath: string | null
error: string | null
createdAt: string
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
finished_at: string | null
}
function rowToJob(row: BackupJobRow): BackupJob {
const payload = (() => {
try {
return JSON.parse(row.payload_json) as Record<string, unknown>
} 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,
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, 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, 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<string, unknown>
}): 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, finished_at)
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
`).run(
id,
userId,
input.type,
input.total ?? 0,
input.message ?? '',
payloadJson,
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, 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): BackupJob[] {
return listBackupJobsPage(1, limit, userId).jobs
}
export function countBackupJobs(userId?: number): number {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
`).get(...(userId === undefined ? [] : [userId])) as { count: number }
return row.count
}
export function listBackupJobsPage(
page = 1,
pageSize = 10,
userId?: number
): { 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)
const rows = getDb().prepare(`
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
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, 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, 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.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 = '任务已中断,请重试',
finished_at = ?
WHERE status = 'running'
`).run(finishedAt)
return Number(result.changes ?? 0)
}
export function updateBackupJob(
id: string,
patch: Partial<{
status: BackupJobStatus
progress: number
total: number
message: string
payload: Record<string, unknown>
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,
finishedAt: patch.finishedAt !== undefined ? patch.finishedAt : current.finishedAt
}
getDb().prepare(`
UPDATE backup_jobs
SET status = ?, progress = ?, total = ?, message = ?, payload_json = ?,
output_path = ?, error = ?, finished_at = ?
WHERE id = ?
`).run(
next.status,
next.progress,
next.total,
next.message,
JSON.stringify(next.payload),
next.outputPath,
next.error,
next.finishedAt,
id
)
}
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<string, unknown>
}): 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()
})
}