diff --git a/app/components/storage/BackupRestoreCard.vue b/app/components/storage/BackupRestoreCard.vue index 73b00e0..dd0d59f 100644 --- a/app/components/storage/BackupRestoreCard.vue +++ b/app/components/storage/BackupRestoreCard.vue @@ -54,6 +54,19 @@ function formatTime(value: string) { 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() { fileInput.value?.click() } @@ -90,8 +103,12 @@ async function submit() { archiveName: selectedPackage.value || undefined }) confirmOpen.value = true - } catch { - toast.add({ title: t('storage.backup.previewFailed'), color: 'error' }) + } catch (error: unknown) { + toast.add({ + title: t('storage.backup.previewFailed'), + description: getErrorMessage(error), + color: 'error' + }) } finally { previewLoading.value = false } diff --git a/app/components/storage/SiteSyncCard.vue b/app/components/storage/SiteSyncCard.vue index 278e977..1989e7d 100644 --- a/app/components/storage/SiteSyncCard.vue +++ b/app/components/storage/SiteSyncCard.vue @@ -30,6 +30,48 @@ const conflictMode = ref<'skip' | 'overwrite'>('skip') const direction = ref('push') 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(() => [ { value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') }, { value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') }, @@ -110,7 +152,6 @@ function submit(resume = false) { scope: scope.value, ...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {}) }) - token.value = '' } function startNew() { @@ -131,7 +172,6 @@ function abandonSync() { direction: direction.value, abandonJobId: props.resumableJob.id }) - token.value = '' } function setConflictMode(mode: 'skip' | 'overwrite') { diff --git a/app/composables/useBackup.ts b/app/composables/useBackup.ts index 68799b2..4712a74 100644 --- a/app/composables/useBackup.ts +++ b/app/composables/useBackup.ts @@ -204,12 +204,17 @@ export function useBackup() { archiveName?: string }): Promise { if (input.file) { - const form = new FormData() - form.append('file', input.file) - form.append('dryRun', 'true') const data = await $fetch<{ preview: BackupRestorePreview }>( '/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 } @@ -235,12 +240,18 @@ export function useBackup() { }) { try { if (input.file) { - const form = new FormData() - form.append('file', input.file) - form.append('conflictMode', input.conflictMode) const data = await $fetch<{ job: BackupJobItem }>( '/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() startPolling() diff --git a/server/api/admin/backup/restore.post.ts b/server/api/admin/backup/restore.post.ts index 3445e38..5e98760 100644 --- a/server/api/admin/backup/restore.post.ts +++ b/server/api/admin/backup/restore.post.ts @@ -11,8 +11,9 @@ import { getBackupStagingDir, isSafeBackupFilename } from '../../../utils/backup/paths' -import { basename, join } from 'node:path' -import { promises as fs } from 'node:fs' +import { createWriteStream, promises as fs } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' interface RestoreBody { archiveName?: string @@ -20,6 +21,20 @@ interface RestoreBody { dryRun?: boolean } +async function streamUploadToFile(event: H3Event, archivePath: string): Promise { + 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<{ archivePath: string body: RestoreBody @@ -27,6 +42,38 @@ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{ }> { 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')) { const form = await readMultipartFormData(event) const filePart = form?.find(part => part.name === 'file' && part.data?.length) diff --git a/server/utils/backup/full-restore-preview.test.ts b/server/utils/backup/full-restore-preview.test.ts new file mode 100644 index 0000000..5af06e9 --- /dev/null +++ b/server/utils/backup/full-restore-preview.test.ts @@ -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) + }) +}) diff --git a/server/utils/backup/full-restore.ts b/server/utils/backup/full-restore.ts index 00210e5..8a1f4dd 100644 --- a/server/utils/backup/full-restore.ts +++ b/server/utils/backup/full-restore.ts @@ -1,6 +1,7 @@ import { createReadStream, promises as fs } from 'node:fs' import { join, relative } from 'node:path' import { pipeline } from 'node:stream/promises' +import { DatabaseSync } from 'node:sqlite' import * as tar from 'tar' import { getImageIndexRow } from '../image-index' import { getActiveBackend } from '../storage/resolver' @@ -48,32 +49,53 @@ async function readManifest(stagingRoot: string): Promise { 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 { const stagingRoot = getBackupStagingDir(`preview-${Date.now()}`) try { await extractArchive(archivePath, 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 imageRows = readRestoreImageRows(stagingRoot) let conflictingImages = 0 - for (const file of blobFiles) { - const rel = relative(blobsRoot, file).replace(/\\/g, '/') - if (await backend.head(rel)) { + for (const row of imageRows) { + const keys = [ + ...(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 + break } } return { - imageCount: manifest.imageCount, + imageCount: imageRows.length, imageBytes: manifest.imageBytes, - newImages: Math.max(0, blobFiles.length - conflictingImages), + newImages: imageRows.length - conflictingImages, conflictingImages, formatVersion: manifest.formatVersion, appVersion: manifest.appVersion,