备份与恢复
This commit is contained in:
1 parent
5d5fecde1b
commit
b4535d6aea
6 files changed
+252
-27
No files matched your search
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user