feat: 完善媒体管理与备份迁移流程
- 视频上传生成缩略图并提供占位封面和失败原因展示 - 支持 HLS 按需重新封装 MP4,并限制临时文件生命周期与访问权限 - 完善视频、文件 API 文档、搜索、批量删除和单次转码质量参数 - 修复备份恢复与跨后端迁移遗漏缩略图、HLS 清单和分片的问题 - 将备份任务、归档和下载权限按用户隔离 - 增加仅清理当前用户数据的高危存储清空流程和二次文字确认 - 修复 S3 中文文件名元数据及 CLI 数据库连接问题 - 补充备份恢复、用户隔离、媒体资源和转码相关测试
This commit is contained in:
1 parent
9ebab8270c
commit
a64be50b73
60 files changed
+2703
-325
No files matched your search
@@ -1,18 +1,22 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
import {
|
||||
createBackendInstance,
|
||||
getStorageBackendRow,
|
||||
setDefaultBackend
|
||||
} from '../storage-backends'
|
||||
import {
|
||||
getImageIndexAsStored,
|
||||
getImageIndexRow,
|
||||
listImageIndexRowsByBackend,
|
||||
updateImageBackendId
|
||||
} from '../image-index'
|
||||
import { createImageStream } from '../storage'
|
||||
import { contentTypeFromKey } from '../content-type'
|
||||
import { mediaTypeForKey } from '../image-key'
|
||||
import type { ImageIndexRow } from '../image-index'
|
||||
import type { StorageBackend } from '../storage/types'
|
||||
import { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs'
|
||||
import { getBackupStagingDir } from './paths'
|
||||
import { collectMediaAssetKeys } from '../media-assets'
|
||||
|
||||
export interface BackendSyncOptions {
|
||||
fromBackendId: string
|
||||
@@ -63,6 +67,44 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
async function syncMediaRow(
|
||||
row: ImageIndexRow,
|
||||
fromBackend: StorageBackend,
|
||||
toBackend: StorageBackend,
|
||||
deleteSource: boolean
|
||||
): Promise<void> {
|
||||
const keys = await collectMediaAssetKeys(row, fromBackend)
|
||||
if (!keys.length) throw new Error('没有可同步的文件对象')
|
||||
|
||||
for (const key of keys) {
|
||||
const stored = await fromBackend.head(key)
|
||||
if (!stored) throw new Error(`源文件不存在:${key}`)
|
||||
|
||||
const bytes = await streamToBuffer(await fromBackend.createStream(key))
|
||||
const isPrimary = key === row.key
|
||||
await toBackend.put(key, bytes, {
|
||||
originalName: isPrimary ? row.original_name : basename(key),
|
||||
uploadedAt: row.uploaded_at,
|
||||
contentType: isPrimary ? row.content_type : contentTypeFromKey(key),
|
||||
size: bytes.length,
|
||||
userId: row.user_id,
|
||||
mediaType: isPrimary ? mediaTypeForKey(key) : 'video'
|
||||
})
|
||||
|
||||
if (!await toBackend.head(key)) {
|
||||
throw new Error(`目标后端写入校验失败:${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
updateImageBackendId(row.key, toBackend.id)
|
||||
|
||||
if (deleteSource) {
|
||||
for (const key of keys) {
|
||||
await fromBackend.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBackendSyncJob(
|
||||
jobId: string,
|
||||
options: BackendSyncOptions
|
||||
@@ -88,32 +130,13 @@ export async function runBackendSyncJob(
|
||||
updateBackupJob(jobId, {
|
||||
total: rows.length,
|
||||
progress: 0,
|
||||
message: '正在同步图片…'
|
||||
message: '正在同步文件…'
|
||||
})
|
||||
|
||||
let processed = 0
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const stream = await createImageStream(row.key)
|
||||
const bytes = await streamToBuffer(stream)
|
||||
await toBackend.put(row.key, bytes, {
|
||||
originalName: row.original_name,
|
||||
uploadedAt: row.uploaded_at,
|
||||
contentType: row.content_type,
|
||||
size: row.size,
|
||||
userId: row.user_id
|
||||
})
|
||||
|
||||
const head = await toBackend.head(row.key)
|
||||
if (!head) {
|
||||
throw new Error('目标后端写入校验失败')
|
||||
}
|
||||
|
||||
updateImageBackendId(row.key, options.toBackendId)
|
||||
|
||||
if (options.deleteSource) {
|
||||
await fromBackend.delete(row.key)
|
||||
}
|
||||
await syncMediaRow(row, fromBackend, toBackend, Boolean(options.deleteSource))
|
||||
} catch (error) {
|
||||
failedKeys.push(row.key)
|
||||
await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {})
|
||||
@@ -136,7 +159,7 @@ export async function runBackendSyncJob(
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
|
||||
markJobFailed(jobId, `${failedKeys.length} 个文件同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
|
||||
updateBackupJob(jobId, {
|
||||
payload: { failedKeys, failedLogPath, preview }
|
||||
})
|
||||
@@ -178,22 +201,10 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
|
||||
|
||||
const stillFailed: string[] = []
|
||||
for (const key of keys) {
|
||||
const row = getImageIndexAsStored(key)
|
||||
const row = getImageIndexRow(key)
|
||||
if (!row) continue
|
||||
try {
|
||||
const stream = await createImageStream(key)
|
||||
const bytes = await streamToBuffer(stream)
|
||||
await toBackend.put(key, bytes, {
|
||||
originalName: row.originalName,
|
||||
uploadedAt: row.uploadedAt,
|
||||
contentType: row.contentType,
|
||||
size: row.size,
|
||||
userId: row.userId
|
||||
})
|
||||
updateImageBackendId(key, toBackendId)
|
||||
if (deleteSource) {
|
||||
await fromBackend.delete(key)
|
||||
}
|
||||
await syncMediaRow(row, fromBackend, toBackend, deleteSource)
|
||||
} catch {
|
||||
stillFailed.push(key)
|
||||
}
|
||||
@@ -205,7 +216,7 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
|
||||
stillFailed.map(key => `${key}\tretry failed\n`).join(''),
|
||||
'utf8'
|
||||
)
|
||||
throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`)
|
||||
throw new Error(`仍有 ${stillFailed.length} 个文件同步失败`)
|
||||
}
|
||||
|
||||
await fs.rm(failedLogPath, { force: true }).catch(() => {})
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as tar from 'tar'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureDefaultBackends } from '../storage-backends'
|
||||
import { getActiveBackend } from '../storage/resolver'
|
||||
import { putImage } from '../storage'
|
||||
import { updateImageThumb, updateImageTranscode } from '../image-index'
|
||||
import { createBackupJob, getBackupJob } from './jobs'
|
||||
import { runFullExportJob } from './full-export'
|
||||
import { runFullRestoreJob } from './full-restore'
|
||||
|
||||
async function archiveEntries(path: string): Promise<string[]> {
|
||||
const entries: string[] = []
|
||||
await tar.t({
|
||||
file: path,
|
||||
onentry: (entry) => {
|
||||
if (entry.type === 'File') entries.push(entry.path.replace(/^\.\//, ''))
|
||||
}
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
describe('runFullExportJob', () => {
|
||||
it('includes video source, thumbnail and HLS artifacts', async () => {
|
||||
ensureDefaultBackends()
|
||||
const key = 'videos/2026/10/backup-test.mp4'
|
||||
const thumbKey = 'videos/2026/10/backup-test.webp'
|
||||
const hlsKey = 'videos/2026/10/backup-test/index.m3u8'
|
||||
const segmentKey = 'videos/2026/10/backup-test/seg000.ts'
|
||||
const meta = {
|
||||
originalName: 'backup-test.mp4',
|
||||
uploadedAt: '2026-10-17T00:00:00.000Z',
|
||||
contentType: 'video/mp4',
|
||||
size: 1,
|
||||
userId: 1,
|
||||
mediaType: 'video' as const
|
||||
}
|
||||
|
||||
await putImage(key, new Uint8Array([1]), meta)
|
||||
const backend = await getActiveBackend()
|
||||
await backend.put(thumbKey, new Uint8Array([2]), meta)
|
||||
await backend.put(
|
||||
hlsKey,
|
||||
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
|
||||
meta
|
||||
)
|
||||
await backend.put(segmentKey, new Uint8Array([3]), meta)
|
||||
updateImageThumb(key, thumbKey)
|
||||
updateImageTranscode(key, {
|
||||
status: 'done',
|
||||
hlsKey,
|
||||
progress: 100
|
||||
})
|
||||
|
||||
const job = createBackupJob({ type: 'full_export', message: 'test' })
|
||||
await runFullExportJob(job.id, 'test')
|
||||
|
||||
const done = getBackupJob(job.id)
|
||||
expect(done?.status).toBe('done')
|
||||
const entries = await archiveEntries(done!.outputPath!)
|
||||
expect(entries).toEqual(expect.arrayContaining([
|
||||
'blobs/videos/2026/10/backup-test.mp4',
|
||||
'blobs/videos/2026/10/backup-test.webp',
|
||||
'blobs/videos/2026/10/backup-test/index.m3u8',
|
||||
'blobs/videos/2026/10/backup-test/seg000.ts'
|
||||
]))
|
||||
})
|
||||
|
||||
it('restores missing sidecar files in skip mode', async () => {
|
||||
ensureDefaultBackends()
|
||||
const key = 'videos/2026/10/restore-test.mp4'
|
||||
const thumbKey = 'videos/2026/10/restore-test.webp'
|
||||
const hlsKey = 'videos/2026/10/restore-test/index.m3u8'
|
||||
const segmentKey = 'videos/2026/10/restore-test/seg000.ts'
|
||||
const meta = {
|
||||
originalName: 'restore-test.mp4',
|
||||
uploadedAt: '2026-10-17T00:00:00.000Z',
|
||||
contentType: 'video/mp4',
|
||||
size: 1,
|
||||
userId: 1,
|
||||
mediaType: 'video' as const
|
||||
}
|
||||
|
||||
await putImage(key, new Uint8Array([1]), meta)
|
||||
const backend = await getActiveBackend()
|
||||
await backend.put(thumbKey, new Uint8Array([2]), meta)
|
||||
await backend.put(
|
||||
hlsKey,
|
||||
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
|
||||
meta
|
||||
)
|
||||
await backend.put(segmentKey, new Uint8Array([3]), meta)
|
||||
updateImageThumb(key, thumbKey)
|
||||
updateImageTranscode(key, { status: 'done', hlsKey, progress: 100 })
|
||||
|
||||
const exportJob = createBackupJob({ type: 'full_export', message: 'test' })
|
||||
await runFullExportJob(exportJob.id, 'test')
|
||||
const archivePath = getBackupJob(exportJob.id)!.outputPath!
|
||||
|
||||
await backend.delete(segmentKey)
|
||||
expect(await backend.head(segmentKey)).toBeNull()
|
||||
|
||||
const restoreJob = createBackupJob({ type: 'full_restore', message: 'test' })
|
||||
await runFullRestoreJob(restoreJob.id, archivePath, { conflictMode: 'skip' })
|
||||
expect(await backend.head(segmentKey)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createWriteStream, promises as fs } from 'node:fs'
|
||||
import { basename, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import * as tar from 'tar'
|
||||
import { getImageTotals, listAllImageIndexRows } from '../image-index'
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../storage-backends'
|
||||
import { parseS3Config } from '../storage/s3'
|
||||
import { parseTelegramConfig } from '../storage/telegram'
|
||||
import { createImageStream } from '../storage'
|
||||
import { getBackendForKey } from '../storage/resolver'
|
||||
import { copyDatabaseSnapshot } from './db-snapshot'
|
||||
import { markJobDone, markJobFailed, updateBackupJob } from './jobs'
|
||||
import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getBackupsDir
|
||||
} from './paths'
|
||||
import type { StorageBackendRow } from '../storage/types'
|
||||
import { collectMediaAssetKeys } from '../media-assets'
|
||||
|
||||
function formatArchiveName(jobId: string): string {
|
||||
return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}`
|
||||
@@ -48,36 +49,15 @@ async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string
|
||||
await pipeline(stream, createWriteStream(filePath))
|
||||
}
|
||||
|
||||
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk as Uint8Array))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* 源文件已按设计删除(转码完成)时,导出 HLS 播放列表、分片与封面。
|
||||
* 恢复端会遍历 blobs 目录原样还原,因此这里只需把附属文件写进去。
|
||||
*/
|
||||
async function exportVideoSidecars(row: ImageIndexRow, stagingRoot: string): Promise<void> {
|
||||
const keys: string[] = []
|
||||
if (row.thumb_key) keys.push(row.thumb_key)
|
||||
|
||||
if (row.hls_key) {
|
||||
keys.push(row.hls_key)
|
||||
const dir = row.hls_key.slice(0, row.hls_key.lastIndexOf('/'))
|
||||
const text = await streamToString(await createImageStream(row.hls_key))
|
||||
for (const line of text.split('\n')) {
|
||||
const name = line.trim()
|
||||
if (!name || name.startsWith('#')) continue
|
||||
keys.push(`${dir}/${basename(name)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMediaAssets(
|
||||
row: ImageIndexRow,
|
||||
stagingRoot: string
|
||||
): Promise<void> {
|
||||
const backend = await getBackendForKey(row.key)
|
||||
const keys = await collectMediaAssetKeys(row, backend)
|
||||
for (const key of keys) {
|
||||
await writeStreamToFile(
|
||||
await createImageStream(key),
|
||||
await backend.createStream(key),
|
||||
join(stagingRoot, 'blobs', key)
|
||||
)
|
||||
}
|
||||
@@ -138,17 +118,10 @@ export async function runFullExportJob(jobId: string, appVersion: string): Promi
|
||||
for (const row of rows) {
|
||||
updateBackupJob(jobId, {
|
||||
progress: processed,
|
||||
message: `正在导出图片 ${processed}/${totals.count + 1}…`
|
||||
message: `正在导出文件 ${processed}/${totals.count + 1}…`
|
||||
})
|
||||
|
||||
if (Number(row.source_deleted ?? 0) === 1) {
|
||||
// 转码完成的视频只有 HLS 产物与封面
|
||||
await exportVideoSidecars(row, stagingRoot)
|
||||
} else {
|
||||
const blobPath = join(stagingRoot, 'blobs', row.key)
|
||||
const stream = await createImageStream(row.key)
|
||||
await writeStreamToFile(stream, blobPath)
|
||||
}
|
||||
await exportMediaAssets(row, stagingRoot)
|
||||
processed += 1
|
||||
updateBackupJob(jobId, { progress: processed })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { pipeline } from 'node:stream/promises'
|
||||
import * as tar from 'tar'
|
||||
import { getImageIndexRow } from '../image-index'
|
||||
import { getActiveBackend } from '../storage/resolver'
|
||||
import { contentTypeFromKey } from '../content-type'
|
||||
import { replaceDatabaseFromSnapshot } from './db-snapshot'
|
||||
import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs'
|
||||
import {
|
||||
@@ -60,10 +61,11 @@ export async function previewFullRestore(archivePath: string): Promise<BackupRes
|
||||
blobFiles = []
|
||||
}
|
||||
|
||||
const backend = await getActiveBackend()
|
||||
let conflictingImages = 0
|
||||
for (const file of blobFiles) {
|
||||
const rel = relative(blobsRoot, file).replace(/\\/g, '/')
|
||||
if (getImageIndexRow(rel)) {
|
||||
if (await backend.head(rel)) {
|
||||
conflictingImages += 1
|
||||
}
|
||||
}
|
||||
@@ -104,7 +106,7 @@ export async function runFullRestoreJob(
|
||||
restoreBackupJobsAfterReplace(preservedJobs)
|
||||
updateBackupJob(jobId, {
|
||||
status: 'running',
|
||||
message: '正在恢复图片…',
|
||||
message: '正在恢复文件…',
|
||||
progress: 0,
|
||||
total: 0
|
||||
})
|
||||
@@ -112,7 +114,7 @@ export async function runFullRestoreJob(
|
||||
const blobsRoot = join(stagingRoot, 'blobs')
|
||||
const blobFiles = await walkFiles(blobsRoot)
|
||||
const total = blobFiles.length + 1
|
||||
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复图片…' })
|
||||
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复文件…' })
|
||||
|
||||
const backend = await getActiveBackend()
|
||||
let processed = 1
|
||||
@@ -120,8 +122,9 @@ export async function runFullRestoreJob(
|
||||
for (const file of blobFiles) {
|
||||
const key = relative(blobsRoot, file).replace(/\\/g, '/')
|
||||
const indexed = getImageIndexRow(key)
|
||||
const exists = Boolean(await backend.head(key))
|
||||
|
||||
if (!indexed && options.conflictMode === 'skip') {
|
||||
if (exists && options.conflictMode === 'skip') {
|
||||
processed += 1
|
||||
continue
|
||||
}
|
||||
@@ -138,19 +141,19 @@ export async function runFullRestoreJob(
|
||||
: {
|
||||
originalName: key.split('/').pop() ?? key,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
contentType: 'application/octet-stream',
|
||||
contentType: contentTypeFromKey(key),
|
||||
size: bytes.length,
|
||||
userId: null
|
||||
}
|
||||
|
||||
if (options.conflictMode === 'overwrite' || !indexed) {
|
||||
if (options.conflictMode === 'overwrite' || !exists) {
|
||||
await backend.put(key, bytes, meta)
|
||||
}
|
||||
|
||||
processed += 1
|
||||
updateBackupJob(jobId, {
|
||||
progress: processed,
|
||||
message: `正在恢复图片 ${processed}/${total}…`
|
||||
message: `正在恢复文件 ${processed}/${total}…`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,7 +162,7 @@ export async function runFullRestoreJob(
|
||||
markJobDone(jobId, {
|
||||
message: '恢复完成,请重新登录并在存储页检查对象存储密钥',
|
||||
payload: {
|
||||
restoredImages: blobFiles.length,
|
||||
restoredFiles: blobFiles.length,
|
||||
manifest
|
||||
}
|
||||
})
|
||||
|
||||
+58
-25
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { getDb } from '../db'
|
||||
import { getDb, getPrimaryAdminUserId } from '../db'
|
||||
import { ensureBackupSchema } from './schema'
|
||||
|
||||
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync'
|
||||
@@ -7,6 +7,7 @@ export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
|
||||
|
||||
export interface BackupJob {
|
||||
id: string
|
||||
userId: number | null
|
||||
type: BackupJobType
|
||||
status: BackupJobStatus
|
||||
progress: number
|
||||
@@ -21,6 +22,7 @@ export interface BackupJob {
|
||||
|
||||
interface BackupJobRow {
|
||||
id: string
|
||||
user_id: number | null
|
||||
type: BackupJobType
|
||||
status: BackupJobStatus
|
||||
progress: number
|
||||
@@ -43,6 +45,7 @@ function rowToJob(row: BackupJobRow): BackupJob {
|
||||
})()
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id ?? null,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
progress: row.progress,
|
||||
@@ -62,33 +65,34 @@ function generateJobId(): string {
|
||||
return `${stamp}-${suffix}`
|
||||
}
|
||||
|
||||
export function getRunningBackupJob(): BackupJob | null {
|
||||
export function getRunningBackupJob(userId?: number): BackupJob | null {
|
||||
ensureBackupSchema()
|
||||
const row = getDb().prepare(`
|
||||
SELECT id, type, status, progress, total, message, payload_json,
|
||||
SELECT id, user_id, type, status, progress, total, message, payload_json,
|
||||
output_path, error, created_at, finished_at
|
||||
FROM backup_jobs
|
||||
WHERE status = 'running'
|
||||
WHERE status = 'running'${userId === undefined ? '' : ' AND user_id = ?'}
|
||||
LIMIT 1
|
||||
`).get() as BackupJobRow | undefined
|
||||
`).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
|
||||
return row ? rowToJob(row) : null
|
||||
}
|
||||
|
||||
export function getActiveBackupJob(): BackupJob | null {
|
||||
export function getActiveBackupJob(userId?: number): BackupJob | null {
|
||||
ensureBackupSchema()
|
||||
const row = getDb().prepare(`
|
||||
SELECT id, type, status, progress, total, message, payload_json,
|
||||
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')
|
||||
WHERE status IN ('pending', 'running')${userId === undefined ? '' : ' AND user_id = ?'}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`).get() as BackupJobRow | undefined
|
||||
`).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>
|
||||
@@ -97,13 +101,15 @@ export function createBackupJob(input: {
|
||||
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, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
|
||||
VALUES (?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
|
||||
(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 ?? '',
|
||||
@@ -114,43 +120,69 @@ export function createBackupJob(input: {
|
||||
return getBackupJob(id)!
|
||||
}
|
||||
|
||||
export function getBackupJob(id: string): BackupJob | null {
|
||||
export function getBackupJob(id: string, userId?: number): BackupJob | null {
|
||||
ensureBackupSchema()
|
||||
const row = getDb().prepare(`
|
||||
SELECT id, type, status, progress, total, message, payload_json,
|
||||
SELECT id, user_id, type, status, progress, total, message, payload_json,
|
||||
output_path, error, created_at, finished_at
|
||||
FROM backup_jobs
|
||||
WHERE id = ?
|
||||
`).get(id) as BackupJobRow | undefined
|
||||
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): BackupJob[] {
|
||||
return listBackupJobsPage(1, limit).jobs
|
||||
export function listBackupJobs(limit = 20, userId?: number): BackupJob[] {
|
||||
return listBackupJobsPage(1, limit, userId).jobs
|
||||
}
|
||||
|
||||
export function countBackupJobs(): number {
|
||||
export function countBackupJobs(userId?: number): number {
|
||||
ensureBackupSchema()
|
||||
const row = getDb().prepare('SELECT COUNT(*) AS count FROM backup_jobs').get() as { count: number }
|
||||
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): { jobs: BackupJob[], total: number } {
|
||||
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()
|
||||
const total = countBackupJobs(userId)
|
||||
const rows = getDb().prepare(`
|
||||
SELECT id, type, status, progress, total, message, payload_json,
|
||||
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(limit, offset) as unknown as BackupJobRow[]
|
||||
`).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)
|
||||
@@ -165,12 +197,13 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void {
|
||||
db.prepare('DELETE FROM backup_jobs').run()
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO backup_jobs
|
||||
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(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,
|
||||
|
||||
@@ -70,10 +70,11 @@ export function scheduleBackupJob(
|
||||
})
|
||||
}
|
||||
|
||||
export function startFullExportJob(appVersion: string): BackupJob {
|
||||
export function startFullExportJob(appVersion: string, userId?: number | null): BackupJob {
|
||||
claimJobSlot()
|
||||
const job = createBackupJob({
|
||||
type: 'full_export',
|
||||
userId,
|
||||
message: '等待开始…'
|
||||
})
|
||||
scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion))
|
||||
@@ -82,11 +83,13 @@ export function startFullExportJob(appVersion: string): BackupJob {
|
||||
|
||||
export function startFullRestoreJob(
|
||||
archivePath: string,
|
||||
conflictMode: 'skip' | 'overwrite'
|
||||
conflictMode: 'skip' | 'overwrite',
|
||||
userId?: number | null
|
||||
): BackupJob {
|
||||
claimJobSlot()
|
||||
const job = createBackupJob({
|
||||
type: 'full_restore',
|
||||
userId,
|
||||
message: '等待开始…',
|
||||
payload: { archivePath, conflictMode }
|
||||
})
|
||||
@@ -100,10 +103,11 @@ export function startBackendSyncJob(options: {
|
||||
deleteSource?: boolean
|
||||
setDefault?: boolean
|
||||
dryRun?: boolean
|
||||
}): BackupJob {
|
||||
}, userId?: number | null): BackupJob {
|
||||
claimJobSlot()
|
||||
const job = createBackupJob({
|
||||
type: 'backend_sync',
|
||||
userId,
|
||||
message: options.dryRun ? '预检中…' : '等待开始…',
|
||||
payload: options
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ export function ensureBackupSchema(): void {
|
||||
getDb().exec(`
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')),
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')),
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -18,4 +19,21 @@ export function ensureBackupSchema(): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at
|
||||
ON backup_jobs(created_at DESC);
|
||||
`)
|
||||
|
||||
const columns = getDb().prepare('PRAGMA table_info(backup_jobs)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
if (!columns.some(column => column.name === 'user_id')) {
|
||||
getDb().exec('ALTER TABLE backup_jobs ADD COLUMN user_id INTEGER')
|
||||
}
|
||||
getDb().exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_jobs_user_created
|
||||
ON backup_jobs(user_id, created_at DESC);
|
||||
UPDATE backup_jobs
|
||||
SET user_id = (
|
||||
SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1
|
||||
)
|
||||
WHERE user_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM users WHERE role = 'admin');
|
||||
`)
|
||||
}
|
||||
Reference in new issue
Block a user