备份与恢复

This commit is contained in:
Yeomai committed 2026-10-18 20:28:46 +08:00
1 parent 5d5fecde1b
commit b4535d6aea
6 files changed
+252 -27

No files matched your search

+19 -2
View File
@@ -54,6 +54,19 @@ function formatTime(value: string) {
return new Date(value).toLocaleString(locale.value, { hour12: false }) return new Date(value).toLocaleString(locale.value, { hour12: false })
} }
function getErrorMessage(error: unknown) {
const value = error as {
data?: { data?: { message?: string }, message?: string }
statusMessage?: string
message?: string
}
return value?.data?.data?.message
?? value?.data?.message
?? value?.statusMessage
?? value?.message
?? t('storage.backup.previewFailed')
}
function pickFile() { function pickFile() {
fileInput.value?.click() fileInput.value?.click()
} }
@@ -90,8 +103,12 @@ async function submit() {
archiveName: selectedPackage.value || undefined archiveName: selectedPackage.value || undefined
}) })
confirmOpen.value = true confirmOpen.value = true
} catch { } catch (error: unknown) {
toast.add({ title: t('storage.backup.previewFailed'), color: 'error' }) toast.add({
title: t('storage.backup.previewFailed'),
description: getErrorMessage(error),
color: 'error'
})
} finally { } finally {
previewLoading.value = false previewLoading.value = false
} }
+42 -2
View File
@@ -30,6 +30,48 @@ const conflictMode = ref<'skip' | 'overwrite'>('skip')
const direction = ref<SiteSyncDirection>('push') const direction = ref<SiteSyncDirection>('push')
const scope = ref<'all' | 'image' | 'video' | 'file'>('all') const scope = ref<'all' | 'image' | 'video' | 'file'>('all')
/** 目标站点地址与 Token 自动保存 24 小时 */
const FORM_STORAGE_KEY = 'pichost.site-sync-form'
const FORM_TTL_MS = 24 * 60 * 60 * 1000
function loadSavedForm() {
if (!import.meta.client) return
try {
const raw = localStorage.getItem(FORM_STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as {
targetBaseUrl?: string
token?: string
savedAt?: number
}
if (!data.savedAt || Date.now() - data.savedAt > FORM_TTL_MS) {
localStorage.removeItem(FORM_STORAGE_KEY)
return
}
if (data.targetBaseUrl) targetBaseUrl.value = data.targetBaseUrl
if (data.token) token.value = data.token
} catch {
localStorage.removeItem(FORM_STORAGE_KEY)
}
}
function persistForm() {
if (!import.meta.client) return
try {
localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify({
targetBaseUrl: targetBaseUrl.value,
token: token.value,
savedAt: Date.now()
}))
} catch {
// 存储不可用时忽略
}
}
watch([targetBaseUrl, token], persistForm)
onMounted(loadSavedForm)
const scopeItems = computed(() => [ const scopeItems = computed(() => [
{ value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') }, { value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') },
{ value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') }, { value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') },
@@ -110,7 +152,6 @@ function submit(resume = false) {
scope: scope.value, scope: scope.value,
...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {}) ...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {})
}) })
token.value = ''
} }
function startNew() { function startNew() {
@@ -131,7 +172,6 @@ function abandonSync() {
direction: direction.value, direction: direction.value,
abandonJobId: props.resumableJob.id abandonJobId: props.resumableJob.id
}) })
token.value = ''
} }
function setConflictMode(mode: 'skip' | 'overwrite') { function setConflictMode(mode: 'skip' | 'overwrite') {
+19 -8
View File
@@ -204,12 +204,17 @@ export function useBackup() {
archiveName?: string archiveName?: string
}): Promise<BackupRestorePreview> { }): Promise<BackupRestorePreview> {
if (input.file) { if (input.file) {
const form = new FormData()
form.append('file', input.file)
form.append('dryRun', 'true')
const data = await $fetch<{ preview: BackupRestorePreview }>( const data = await $fetch<{ preview: BackupRestorePreview }>(
'/api/admin/backup/restore', '/api/admin/backup/restore',
{ method: 'POST', credentials: 'include', body: form } {
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'true'
}
}
) )
return data.preview return data.preview
} }
@@ -235,12 +240,18 @@ export function useBackup() {
}) { }) {
try { try {
if (input.file) { if (input.file) {
const form = new FormData()
form.append('file', input.file)
form.append('conflictMode', input.conflictMode)
const data = await $fetch<{ job: BackupJobItem }>( const data = await $fetch<{ job: BackupJobItem }>(
'/api/admin/backup/restore', '/api/admin/backup/restore',
{ method: 'POST', credentials: 'include', body: form } {
method: 'POST',
credentials: 'include',
body: input.file,
headers: {
'content-type': 'application/octet-stream',
'x-pichost-dry-run': 'false',
'x-pichost-conflict-mode': input.conflictMode
}
}
) )
await refreshJobsFirstPage() await refreshJobsFirstPage()
startPolling() startPolling()
+49 -2
View File
@@ -11,8 +11,9 @@ import {
getBackupStagingDir, getBackupStagingDir,
isSafeBackupFilename isSafeBackupFilename
} from '../../../utils/backup/paths' } from '../../../utils/backup/paths'
import { basename, join } from 'node:path' import { createWriteStream, promises as fs } from 'node:fs'
import { promises as fs } from 'node:fs' import { basename, dirname, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
interface RestoreBody { interface RestoreBody {
archiveName?: string archiveName?: string
@@ -20,6 +21,20 @@ interface RestoreBody {
dryRun?: boolean dryRun?: boolean
} }
async function streamUploadToFile(event: H3Event, archivePath: string): Promise<void> {
await fs.mkdir(dirname(archivePath), { recursive: true })
try {
await pipeline(event.node.req, createWriteStream(archivePath))
const stat = await fs.stat(archivePath)
if (stat.size === 0) {
throw new Error('备份文件为空')
}
} catch (error) {
await fs.rm(archivePath, { force: true }).catch(() => {})
throw error
}
}
async function resolveRestoreInput(event: H3Event, userId: number): Promise<{ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{
archivePath: string archivePath: string
body: RestoreBody body: RestoreBody
@@ -27,6 +42,38 @@ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{
}> { }> {
const contentType = getHeader(event, 'content-type') ?? '' const contentType = getHeader(event, 'content-type') ?? ''
if (contentType.includes('application/octet-stream')) {
const dryRun = getHeader(event, 'x-pichost-dry-run') === 'true'
const conflictMode = getHeader(event, 'x-pichost-conflict-mode') === 'skip'
? 'skip'
: 'overwrite'
if (dryRun) {
const tempDir = getBackupStagingDir(`upload-preview-${Date.now()}`)
await fs.mkdir(tempDir, { recursive: true })
const archivePath = join(tempDir, 'package.phost.tar.gz')
await streamUploadToFile(event, archivePath)
return {
archivePath,
body: { conflictMode, dryRun: true },
cleanup: async () => {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
}
}
await fs.mkdir(getBackupsDir(), { recursive: true })
const archivePath = join(
getBackupsDir(),
`upload-${Date.now()}${BACKUP_ARCHIVE_EXT}`
)
await streamUploadToFile(event, archivePath)
return {
archivePath,
body: { conflictMode, dryRun: false }
}
}
if (contentType.includes('multipart/form-data')) { if (contentType.includes('multipart/form-data')) {
const form = await readMultipartFormData(event) const form = await readMultipartFormData(event)
const filePart = form?.find(part => part.name === 'file' && part.data?.length) const filePart = form?.find(part => part.name === 'file' && part.data?.length)
@@ -0,0 +1,88 @@
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 { previewFullRestore } from './full-restore'
describe('previewFullRestore', () => {
it('counts media records instead of HLS sidecar files', async () => {
ensureDefaultBackends()
const key = 'videos/2026/10/preview-count.mp4'
const thumbKey = 'videos/2026/10/preview-count.webp'
const hlsKey = 'videos/2026/10/preview-count/index.m3u8'
const segmentKey = 'videos/2026/10/preview-count/seg000.ts'
const hlsOnlyKey = 'videos/2026/10/preview-count-hls-only.mp4'
const hlsOnlyThumbKey = 'videos/2026/10/preview-count-hls-only.webp'
const hlsOnlyPlaylistKey = 'videos/2026/10/preview-count-hls-only/index.m3u8'
const hlsOnlySegmentKey = 'videos/2026/10/preview-count-hls-only/seg000.ts'
const meta = {
originalName: 'preview-count.mp4',
uploadedAt: '2026-10-18T00: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 })
await putImage(hlsOnlyKey, new Uint8Array([4]), meta)
await backend.put(hlsOnlyThumbKey, new Uint8Array([5]), meta)
await backend.put(
hlsOnlyPlaylistKey,
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
meta
)
await backend.put(hlsOnlySegmentKey, new Uint8Array([6]), meta)
updateImageThumb(hlsOnlyKey, hlsOnlyThumbKey)
updateImageTranscode(hlsOnlyKey, {
status: 'done',
hlsKey: hlsOnlyPlaylistKey,
progress: 100,
sourceDeleted: true
})
const exportJob = createBackupJob({ type: 'full_export', message: 'test' })
await runFullExportJob(exportJob.id, 'test')
const archivePath = getBackupJob(exportJob.id)!.outputPath!
for (const fileKey of [
key,
thumbKey,
hlsKey,
segmentKey,
hlsOnlyThumbKey,
hlsOnlyPlaylistKey,
hlsOnlySegmentKey
]) {
await backend.delete(fileKey)
}
const preview = await previewFullRestore(archivePath)
expect(preview.imageCount).toBe(2)
expect(preview.newImages).toBe(2)
expect(preview.conflictingImages).toBe(0)
await backend.put(
hlsOnlyPlaylistKey,
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
meta
)
const conflictPreview = await previewFullRestore(archivePath)
expect(conflictPreview.newImages).toBe(1)
expect(conflictPreview.conflictingImages).toBe(1)
})
})
+35 -13
View File
@@ -1,6 +1,7 @@
import { createReadStream, promises as fs } from 'node:fs' import { createReadStream, promises as fs } from 'node:fs'
import { join, relative } from 'node:path' import { join, relative } from 'node:path'
import { pipeline } from 'node:stream/promises' import { pipeline } from 'node:stream/promises'
import { DatabaseSync } from 'node:sqlite'
import * as tar from 'tar' import * as tar from 'tar'
import { getImageIndexRow } from '../image-index' import { getImageIndexRow } from '../image-index'
import { getActiveBackend } from '../storage/resolver' import { getActiveBackend } from '../storage/resolver'
@@ -48,32 +49,53 @@ async function readManifest(stagingRoot: string): Promise<BackupManifest> {
return manifest return manifest
} }
interface RestoreImageRow {
key: string
thumb_key: string | null
hls_key: string | null
source_deleted: number
}
function readRestoreImageRows(stagingRoot: string): RestoreImageRow[] {
const db = new DatabaseSync(
join(stagingRoot, 'database', 'pichost.sqlite'),
{ readOnly: true }
)
try {
return db.prepare(`
SELECT key, thumb_key, hls_key, source_deleted
FROM images
`).all() as unknown as RestoreImageRow[]
} finally {
db.close()
}
}
export async function previewFullRestore(archivePath: string): Promise<BackupRestorePreview> { export async function previewFullRestore(archivePath: string): Promise<BackupRestorePreview> {
const stagingRoot = getBackupStagingDir(`preview-${Date.now()}`) const stagingRoot = getBackupStagingDir(`preview-${Date.now()}`)
try { try {
await extractArchive(archivePath, stagingRoot) await extractArchive(archivePath, stagingRoot)
const manifest = await readManifest(stagingRoot) const manifest = await readManifest(stagingRoot)
const blobsRoot = join(stagingRoot, 'blobs')
let blobFiles: string[] = []
try {
blobFiles = await walkFiles(blobsRoot)
} catch {
blobFiles = []
}
const backend = await getActiveBackend() const backend = await getActiveBackend()
const imageRows = readRestoreImageRows(stagingRoot)
let conflictingImages = 0 let conflictingImages = 0
for (const file of blobFiles) { for (const row of imageRows) {
const rel = relative(blobsRoot, file).replace(/\\/g, '/') const keys = [
if (await backend.head(rel)) { ...(Number(row.source_deleted) === 1 ? [] : [row.key]),
...(row.thumb_key ? [row.thumb_key] : []),
...(row.hls_key ? [row.hls_key] : [])
]
for (const key of keys) {
if (!await backend.head(key)) continue
conflictingImages += 1 conflictingImages += 1
break
} }
} }
return { return {
imageCount: manifest.imageCount, imageCount: imageRows.length,
imageBytes: manifest.imageBytes, imageBytes: manifest.imageBytes,
newImages: Math.max(0, blobFiles.length - conflictingImages), newImages: imageRows.length - conflictingImages,
conflictingImages, conflictingImages,
formatVersion: manifest.formatVersion, formatVersion: manifest.formatVersion,
appVersion: manifest.appVersion, appVersion: manifest.appVersion,