diff --git a/app/components/storage/BackupJobTable.vue b/app/components/storage/BackupJobTable.vue
index 2c419b3..22f8374 100644
--- a/app/components/storage/BackupJobTable.vue
+++ b/app/components/storage/BackupJobTable.vue
@@ -8,6 +8,7 @@ defineProps<{
totalPages: number
total: number
pageSize: number
+ showHidden: boolean
loading?: boolean
}>()
@@ -15,6 +16,9 @@ const emit = defineEmits<{
'retry': [jobId: string]
'download': [jobId: string]
'cancel': [jobId: string]
+ 'hide': [jobId: string, hidden: boolean]
+ 'hideAll': [hidden: boolean]
+ 'toggleHidden': []
'view': [job: BackupJobItem]
'update:page': [page: number]
'update:pageSize': [pageSize: number]
@@ -85,6 +89,10 @@ function canView(job: BackupJobItem): boolean {
return job.type === 'full_restore' && job.status === 'done' && Boolean(job.message)
}
+function canHide(job: BackupJobItem): boolean {
+ return job.status === 'done' || job.status === 'failed' || job.hidden
+}
+
function formatTime(value: string) {
if (!value) return '—'
return new Date(value).toLocaleString(locale.value, { hour12: false })
@@ -102,11 +110,33 @@ function formatTime(value: string) {
{{ t('storage.backup.jobsSubtitle') }}
-
+
+
+
+
+
+
diff --git a/app/components/storage/StorageBackupPanel.vue b/app/components/storage/StorageBackupPanel.vue
index f291399..559e2a1 100644
--- a/app/components/storage/StorageBackupPanel.vue
+++ b/app/components/storage/StorageBackupPanel.vue
@@ -19,6 +19,7 @@ const {
jobsTotal,
jobsTotalPages,
jobsLoading,
+ showHiddenJobs,
estimate,
packages,
loadingEstimate,
@@ -26,6 +27,9 @@ const {
hasActiveJob,
getActiveJob,
loadJobs,
+ toggleShowHiddenJobs,
+ hideJob,
+ hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
@@ -98,6 +102,14 @@ async function onCancel(jobId: string) {
await cancelJob(jobId)
}
+async function onHideJob(jobId: string, hidden: boolean) {
+ await hideJob(jobId, hidden)
+}
+
+async function onHideAll(hidden: boolean) {
+ await hideAllJobs(hidden)
+}
+
async function onSync(body: {
fromBackendId: string
toBackendId: string
@@ -192,11 +204,15 @@ function onViewJob(job: BackupJobItem) {
:total="jobsTotal"
:page-size="jobsPageSize"
:loading="jobsLoading"
+ :show-hidden="showHiddenJobs"
@update:page="setJobsPage"
@update:page-size="setJobsPageSize"
@download="onDownload"
@retry="onRetry"
@cancel="onCancel"
+ @hide="onHideJob"
+ @hide-all="onHideAll"
+ @toggle-hidden="toggleShowHiddenJobs"
@view="onViewJob"
/>
diff --git a/app/composables/useBackup.ts b/app/composables/useBackup.ts
index 8a271a7..68799b2 100644
--- a/app/composables/useBackup.ts
+++ b/app/composables/useBackup.ts
@@ -13,6 +13,7 @@ const jobsPageSize = ref(10)
const jobsTotal = ref(0)
const jobsTotalPages = ref(1)
const jobsLoading = ref(false)
+const showHiddenJobs = ref(false)
const activeJobState = ref(null)
const estimate = ref(null)
const packages = ref([])
@@ -69,7 +70,8 @@ export function useBackup() {
credentials: 'include',
query: {
pageNum: jobsPage.value,
- pageSize: jobsPageSize.value
+ pageSize: jobsPageSize.value,
+ includeHidden: showHiddenJobs.value
}
})
jobs.value = data.jobs
@@ -104,6 +106,58 @@ export function useBackup() {
return loadJobs()
}
+ async function toggleShowHiddenJobs() {
+ showHiddenJobs.value = !showHiddenJobs.value
+ jobsPage.value = 1
+ await loadJobs()
+ }
+
+ async function hideJob(jobId: string, hidden = true) {
+ try {
+ await $fetch('/api/admin/backup/jobs/hide', {
+ method: 'POST',
+ credentials: 'include',
+ body: { jobId, hidden }
+ })
+ if (hidden) {
+ showHiddenJobs.value = false
+ jobsPage.value = 1
+ }
+ await loadJobs()
+ toast.add({
+ title: hidden
+ ? t('storage.backup.jobHidden')
+ : t('storage.backup.jobUnhidden'),
+ color: 'success'
+ })
+ } catch (error: unknown) {
+ handleError(error, t('storage.backup.hideFailed'))
+ }
+ }
+
+ async function hideAllJobs(hidden = true) {
+ try {
+ await $fetch('/api/admin/backup/jobs/hide-all', {
+ method: 'POST',
+ credentials: 'include',
+ body: { hidden }
+ })
+ if (hidden) {
+ showHiddenJobs.value = false
+ }
+ jobsPage.value = 1
+ await loadJobs()
+ toast.add({
+ title: hidden
+ ? t('storage.backup.jobsHiddenAll')
+ : t('storage.backup.jobsUnhiddenAll'),
+ color: 'success'
+ })
+ } catch (error: unknown) {
+ handleError(error, t('storage.backup.hideFailed'))
+ }
+ }
+
async function loadEstimate() {
loadingEstimate.value = true
try {
@@ -327,6 +381,7 @@ export function useBackup() {
jobsTotal,
jobsTotalPages,
jobsLoading,
+ showHiddenJobs,
estimate,
packages,
loadingEstimate,
@@ -334,6 +389,9 @@ export function useBackup() {
hasActiveJob,
getActiveJob,
loadJobs,
+ toggleShowHiddenJobs,
+ hideJob,
+ hideAllJobs,
setJobsPage,
setJobsPageSize,
loadEstimate,
diff --git a/app/types/backup.ts b/app/types/backup.ts
index 24dc4ed..d290051 100644
--- a/app/types/backup.ts
+++ b/app/types/backup.ts
@@ -16,6 +16,7 @@ export interface BackupJobItem {
error: string | null
createdAt: string
updatedAt: string
+ hidden: boolean
finishedAt: string | null
}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 65d5e4d..2eaf925 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -738,6 +738,17 @@
"jobsSubtitle": "Track backup, restore, and migration progress",
"jobsEmpty": "No backup or migration jobs yet",
"jobsEmptyHint": "Progress appears here after export, restore, or sync starts",
+ "jobsHideAll": "Hide all",
+ "jobsRestoreAll": "Restore all",
+ "hiddenJobsShow": "Show hidden",
+ "hiddenJobsHide": "Show visible only",
+ "jobHide": "Hide from list (keeps the record)",
+ "jobUnhide": "Show again",
+ "jobHidden": "Job hidden; its record is still retained",
+ "jobUnhidden": "Job is visible again",
+ "jobsHiddenAll": "All finished jobs hidden; records are still retained",
+ "jobsUnhiddenAll": "All hidden jobs are visible again",
+ "hideFailed": "Failed to update job visibility",
"jobsUnit": "jobs",
"colTask": "Task",
"colType": "Type",
diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json
index a50d2f3..ea3b8e8 100644
--- a/i18n/locales/zh-CN.json
+++ b/i18n/locales/zh-CN.json
@@ -738,6 +738,17 @@
"jobsSubtitle": "查看备份、恢复与迁移进度",
"jobsEmpty": "暂无备份或迁移任务",
"jobsEmptyHint": "导出、恢复或同步开始后,进度会显示在这里",
+ "jobsHideAll": "全部清除",
+ "jobsRestoreAll": "恢复全部",
+ "hiddenJobsShow": "显示隐藏记录",
+ "hiddenJobsHide": "只显示未关闭",
+ "jobHide": "关闭显示(不删除记录)",
+ "jobUnhide": "恢复显示",
+ "jobHidden": "任务已关闭显示,记录仍保留",
+ "jobUnhidden": "任务已恢复显示",
+ "jobsHiddenAll": "已关闭全部历史任务的显示,记录仍保留",
+ "jobsUnhiddenAll": "已恢复显示全部历史任务",
+ "hideFailed": "更新任务显示状态失败",
"jobsUnit": "条任务",
"colTask": "任务",
"colType": "类型",
diff --git a/server/api/admin/backup/export.post.ts b/server/api/admin/backup/export.post.ts
index 3174818..4102c07 100644
--- a/server/api/admin/backup/export.post.ts
+++ b/server/api/admin/backup/export.post.ts
@@ -3,11 +3,14 @@ import { createApiError } from '../../../utils/api-error'
import { serializeBackupJob } from '../../../utils/backup/api'
import { getBackupExportEstimate } from '../../../utils/backup/stats'
import { startFullExportJob } from '../../../utils/backup/runner'
+import { resolveRuntimeAppVersion } from '../../../utils/app-version'
export default defineEventHandler(async (event) => {
const user = await requireAdminAuth(event)
const config = useRuntimeConfig(event)
- const appVersion = String(config.appVersion ?? '0.0.0')
+ const appVersion = resolveRuntimeAppVersion(
+ String(config.appVersion ?? '0.0.0')
+ )
const estimate = await getBackupExportEstimate()
diff --git a/server/api/admin/backup/jobs/hide-all.post.ts b/server/api/admin/backup/jobs/hide-all.post.ts
new file mode 100644
index 0000000..9f5683d
--- /dev/null
+++ b/server/api/admin/backup/jobs/hide-all.post.ts
@@ -0,0 +1,14 @@
+import { requireAdminAuth } from '../../../../utils/access'
+import { setAllFinishedBackupJobsHidden } from '../../../../utils/backup/jobs'
+
+interface Body {
+ hidden?: boolean
+}
+
+export default defineEventHandler(async (event) => {
+ const user = await requireAdminAuth(event)
+ const body = await readBody(event).catch(() => null)
+ const hidden = body?.hidden !== false
+ const updated = setAllFinishedBackupJobsHidden(user.id, hidden)
+ return { success: true, hidden, updated }
+})
diff --git a/server/api/admin/backup/jobs/hide.post.ts b/server/api/admin/backup/jobs/hide.post.ts
new file mode 100644
index 0000000..db1c979
--- /dev/null
+++ b/server/api/admin/backup/jobs/hide.post.ts
@@ -0,0 +1,25 @@
+import { requireAdminAuth } from '../../../../utils/access'
+import { createApiError } from '../../../../utils/api-error'
+import {
+ getBackupJob,
+ setBackupJobHidden
+} from '../../../../utils/backup/jobs'
+
+interface Body {
+ jobId?: string
+ hidden?: boolean
+}
+
+export default defineEventHandler(async (event) => {
+ const user = await requireAdminAuth(event)
+ const body = await readBody(event).catch(() => null)
+ const jobId = body?.jobId?.trim() ?? ''
+ if (!jobId || !getBackupJob(jobId, user.id)) {
+ createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
+ }
+ const hidden = body?.hidden !== false
+ if (!setBackupJobHidden(jobId, user.id, hidden)) {
+ createApiError(event, 'INVALID_REQUEST', '更新任务显示状态失败', 400)
+ }
+ return { success: true, hidden }
+})
diff --git a/server/api/admin/backup/jobs/index.get.ts b/server/api/admin/backup/jobs/index.get.ts
index 0c1e8db..a516716 100644
--- a/server/api/admin/backup/jobs/index.get.ts
+++ b/server/api/admin/backup/jobs/index.get.ts
@@ -14,14 +14,21 @@ export default defineEventHandler(async (event) => {
recoverInterruptedBackupJobs()
}
const query = getQuery(event)
+ const includeHidden = query.includeHidden === 'true'
+ || query.includeHidden === '1'
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 10))
const pageNum = Math.max(1, Number(query.pageNum) || Number(query.page) || 1)
- const { jobs, total } = listBackupJobsPage(pageNum, pageSize, user.id)
+ const { jobs, total } = listBackupJobsPage(
+ pageNum,
+ pageSize,
+ user.id,
+ includeHidden
+ )
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const page = total > 0 ? Math.min(pageNum, totalPages) : 1
const slice = page === pageNum
? jobs
- : listBackupJobsPage(page, pageSize, user.id).jobs
+ : listBackupJobsPage(page, pageSize, user.id, includeHidden).jobs
const activeJob = getActiveBackupJob(user.id)
@@ -31,6 +38,7 @@ export default defineEventHandler(async (event) => {
page,
pageSize,
totalPages,
+ includeHidden,
activeJob: activeJob ? serializeBackupJob(activeJob) : null
}
})
diff --git a/server/api/health.get.ts b/server/api/health.get.ts
index b4dc01c..7138403 100644
--- a/server/api/health.get.ts
+++ b/server/api/health.get.ts
@@ -1,7 +1,9 @@
+import { resolveRuntimeAppVersion } from '../utils/app-version'
+
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event)
return {
status: 'ok',
- version: config.appVersion
+ version: resolveRuntimeAppVersion(String(config.appVersion ?? ''))
}
})
diff --git a/server/api/site-sync/chunk-finalize.post.ts b/server/api/site-sync/chunk-finalize.post.ts
new file mode 100644
index 0000000..f343601
--- /dev/null
+++ b/server/api/site-sync/chunk-finalize.post.ts
@@ -0,0 +1,29 @@
+import { requireSiteSyncAuth } from '../../utils/access'
+import { createApiError } from '../../utils/api-error'
+import { validateStorageKey } from '../../utils/image-key'
+import { finalizeSiteSyncChunk } from '../../utils/site-sync/chunks'
+
+interface Body {
+ jobId?: string
+ key?: string
+}
+
+export default defineEventHandler(async (event) => {
+ await requireSiteSyncAuth(event)
+ const body = await readBody(event).catch(() => null)
+ const jobId = body?.jobId?.trim() ?? ''
+ const key = body?.key?.trim() ?? ''
+ if (!jobId || !validateStorageKey(key)) {
+ createApiError(event, 'INVALID_REQUEST', '分块完成参数无效', 400)
+ }
+ try {
+ return await finalizeSiteSyncChunk({ jobId, key })
+ } catch (error) {
+ createApiError(
+ event,
+ 'INVALID_REQUEST',
+ error instanceof Error ? error.message : '分块组装失败',
+ 400
+ )
+ }
+})
diff --git a/server/api/site-sync/chunk-status.get.ts b/server/api/site-sync/chunk-status.get.ts
new file mode 100644
index 0000000..245230c
--- /dev/null
+++ b/server/api/site-sync/chunk-status.get.ts
@@ -0,0 +1,16 @@
+import { getQuery } from 'h3'
+import { requireSiteSyncAuth } from '../../utils/access'
+import { createApiError } from '../../utils/api-error'
+import { validateStorageKey } from '../../utils/image-key'
+import { getSiteSyncChunkStatus } from '../../utils/site-sync/chunks'
+
+export default defineEventHandler(async (event) => {
+ await requireSiteSyncAuth(event)
+ const query = getQuery(event)
+ const jobId = typeof query.jobId === 'string' ? query.jobId.trim() : ''
+ const key = typeof query.key === 'string' ? query.key.trim() : ''
+ if (!jobId || !validateStorageKey(key)) {
+ createApiError(event, 'INVALID_REQUEST', '分块查询参数无效', 400)
+ }
+ return getSiteSyncChunkStatus({ jobId, key })
+})
diff --git a/server/api/site-sync/chunk.post.ts b/server/api/site-sync/chunk.post.ts
new file mode 100644
index 0000000..1093286
--- /dev/null
+++ b/server/api/site-sync/chunk.post.ts
@@ -0,0 +1,54 @@
+import { getHeader, readRawBody } from 'h3'
+import { requireSiteSyncAuth } from '../../utils/access'
+import { createApiError } from '../../utils/api-error'
+import { validateStorageKey } from '../../utils/image-key'
+import {
+ isSiteSyncChunkMetadata,
+ SITE_SYNC_CHUNK_SIZE,
+ writeSiteSyncChunk
+} from '../../utils/site-sync/chunks'
+
+export default defineEventHandler(async (event) => {
+ await requireSiteSyncAuth(event)
+ const encoded = getHeader(event, 'x-pichost-chunk-metadata') ?? ''
+ let metadata: unknown
+ try {
+ metadata = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
+ } catch {
+ createApiError(event, 'INVALID_REQUEST', '分块元数据无效', 400)
+ }
+ if (
+ !isSiteSyncChunkMetadata(metadata)
+ || !validateStorageKey(metadata.key)
+ || !validateStorageKey(metadata.mediaKey)
+ ) {
+ createApiError(event, 'INVALID_REQUEST', '分块元数据无效', 400)
+ }
+
+ const index = Number.parseInt(getHeader(event, 'x-pichost-chunk-index') ?? '', 10)
+ if (!Number.isInteger(index)) {
+ createApiError(event, 'INVALID_REQUEST', '分块序号无效', 400)
+ }
+ const body = await readRawBody(event, false)
+ if (!body?.length) {
+ createApiError(event, 'INVALID_REQUEST', '未收到分块数据', 400)
+ }
+ if (body.length > SITE_SYNC_CHUNK_SIZE) {
+ createApiError(event, 'FILE_TOO_LARGE', '分块超过 4 MB', 413)
+ }
+
+ try {
+ return await writeSiteSyncChunk({
+ metadata,
+ index,
+ bytes: new Uint8Array(body)
+ })
+ } catch (error) {
+ createApiError(
+ event,
+ 'INVALID_REQUEST',
+ error instanceof Error ? error.message : '分块写入失败',
+ 400
+ )
+ }
+})
diff --git a/server/api/site-sync/cleanup.post.ts b/server/api/site-sync/cleanup.post.ts
index 10fd834..e178718 100644
--- a/server/api/site-sync/cleanup.post.ts
+++ b/server/api/site-sync/cleanup.post.ts
@@ -1,6 +1,7 @@
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { cleanupSiteSyncJobArtifacts } from '../../utils/site-sync/artifacts'
+import { cleanupSiteSyncChunkSessions } from '../../utils/site-sync/chunks'
interface Body {
jobId?: string
@@ -13,8 +14,10 @@ export default defineEventHandler(async (event) => {
if (!jobId || jobId.length > 128) {
createApiError(event, 'INVALID_REQUEST', '缺少同步任务 ID', 400)
}
+ const removedChunkSessions = await cleanupSiteSyncChunkSessions(jobId)
return {
success: true,
+ removedChunkSessions,
...await cleanupSiteSyncJobArtifacts(jobId)
}
})
diff --git a/server/api/user/api-token.get.ts b/server/api/user/api-token.get.ts
index 5186fb6..71ff70b 100644
--- a/server/api/user/api-token.get.ts
+++ b/server/api/user/api-token.get.ts
@@ -7,6 +7,7 @@ import {
getVideoBaseUrl,
isHideFolderInUrl
} from '../../utils/env'
+import { resolveRuntimeAppVersion } from '../../utils/app-version'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
@@ -24,7 +25,7 @@ export default defineEventHandler(async (event) => {
videoBaseUrl: getVideoBaseUrl(event),
fileBaseUrl: getFileBaseUrl(event),
hideFolderInUrl: isHideFolderInUrl(event),
- appVersion: config.appVersion as string
+ appVersion: resolveRuntimeAppVersion(String(config.appVersion ?? ''))
}
}
})
diff --git a/server/api/version/latest.get.ts b/server/api/version/latest.get.ts
index 044151c..892a38b 100644
--- a/server/api/version/latest.get.ts
+++ b/server/api/version/latest.get.ts
@@ -1,14 +1,17 @@
import { requireAdminAuth } from '../../utils/access'
import {
fetchLatestGitHubRelease,
- isVersionNewer
+ isVersionNewer,
+ resolveRuntimeAppVersion
} from '../../utils/app-version'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const config = useRuntimeConfig(event)
- const currentVersion = String(config.appVersion ?? '')
+ const currentVersion = resolveRuntimeAppVersion(
+ String(config.appVersion ?? '')
+ )
const query = getQuery(event)
const force = query.refresh === '1' || query.refresh === 'true'
const latest = await fetchLatestGitHubRelease(force)
diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts
index 72a99b7..05ceeb4 100644
--- a/server/plugins/startup.ts
+++ b/server/plugins/startup.ts
@@ -8,6 +8,7 @@ import { ensureDefaultBackends } from '../utils/storage-backends'
import { cleanupLocalAtomicTempFiles } from '../utils/storage/local'
import { cleanupExpiredSiteSyncArtifacts } from '../utils/site-sync/artifacts'
import { recoverInterruptedBackupJobs } from '../utils/backup/jobs'
+import { cleanupExpiredSiteSyncChunkSessions } from '../utils/site-sync/chunks'
import {
cleanupTranscodedSources,
killOrphanFfmpeg,
@@ -49,6 +50,17 @@ export default defineNitroPlugin(() => {
}
})()
+ void (async () => {
+ try {
+ const removed = await cleanupExpiredSiteSyncChunkSessions()
+ if (removed > 0) {
+ logInfo('清理过期站点同步分块', { removed })
+ }
+ } catch (error) {
+ logException('清理站点同步分块失败', error)
+ }
+ })()
+
void (async () => {
try {
const result = await cleanupExpiredSiteSyncArtifacts()
diff --git a/server/utils/app-version.ts b/server/utils/app-version.ts
index 44fdc5b..9873d6a 100644
--- a/server/utils/app-version.ts
+++ b/server/utils/app-version.ts
@@ -1,3 +1,6 @@
+import { readFileSync, statSync } from 'node:fs'
+import { join } from 'node:path'
+
const GITHUB_REPO = 'O96u/PicHost'
const RELEASES_LATEST_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
const CACHE_TTL_MS = 60 * 60 * 1000
@@ -8,6 +11,26 @@ export interface LatestReleaseInfo {
}
let cachedRelease: { fetchedAt: number, value: LatestReleaseInfo | null } | null = null
+let cachedPackageVersion: { mtimeMs: number, version: string } | null = null
+
+/** Read package.json at request time so a hot-reloaded version update is visible without restart. */
+export function resolveRuntimeAppVersion(fallback: string): string {
+ const path = join(process.cwd(), 'package.json')
+ try {
+ const mtimeMs = statSync(path).mtimeMs
+ if (cachedPackageVersion?.mtimeMs === mtimeMs) {
+ return cachedPackageVersion.version
+ }
+ const pkg = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
+ const version = typeof pkg.version === 'string' && pkg.version
+ ? pkg.version
+ : fallback
+ cachedPackageVersion = { mtimeMs, version }
+ return version
+ } catch {
+ return fallback
+ }
+}
export function normalizeVersionTag(tag: string): string {
return tag.trim().replace(/^v/i, '')
diff --git a/server/utils/backup/api.ts b/server/utils/backup/api.ts
index 02b5304..fe9f409 100644
--- a/server/utils/backup/api.ts
+++ b/server/utils/backup/api.ts
@@ -27,6 +27,7 @@ export function serializeBackupJob(job: BackupJob) {
error: job.error,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
+ hidden: job.hidden,
finishedAt: job.finishedAt
}
}
diff --git a/server/utils/backup/jobs.ts b/server/utils/backup/jobs.ts
index 8da3227..47db55e 100644
--- a/server/utils/backup/jobs.ts
+++ b/server/utils/backup/jobs.ts
@@ -18,6 +18,7 @@ export interface BackupJob {
error: string | null
createdAt: string
updatedAt: string
+ hidden: boolean
finishedAt: string | null
}
@@ -34,6 +35,7 @@ interface BackupJobRow {
error: string | null
created_at: string
updated_at: string
+ hidden: number
finished_at: string | null
}
@@ -58,6 +60,7 @@ function rowToJob(row: BackupJobRow): BackupJob {
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
+ hidden: Number(row.hidden) === 1,
finishedAt: row.finished_at
}
}
@@ -72,7 +75,7 @@ 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, updated_at, finished_at
+ output_path, error, created_at, updated_at, hidden, finished_at
FROM backup_jobs
WHERE status = 'running'${userId === undefined ? '' : ' AND user_id = ?'}
LIMIT 1
@@ -84,7 +87,7 @@ 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, updated_at, finished_at
+ output_path, error, created_at, updated_at, hidden, finished_at
FROM backup_jobs
WHERE status IN ('pending', 'running')${userId === undefined ? '' : ' AND user_id = ?'}
ORDER BY created_at DESC
@@ -128,22 +131,33 @@ 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, updated_at, finished_at
+ output_path, error, created_at, updated_at, hidden, 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 listBackupJobs(
+ limit = 20,
+ userId?: number,
+ includeHidden = false
+): BackupJob[] {
+ return listBackupJobsPage(1, limit, userId, includeHidden).jobs
}
-export function countBackupJobs(userId?: number): number {
+export function countBackupJobs(
+ userId?: number,
+ includeHidden = false
+): number {
ensureBackupSchema()
+ const where = [
+ ...(userId === undefined ? [] : ['user_id = ?']),
+ ...(includeHidden ? [] : ['hidden = 0'])
+ ]
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM backup_jobs
- ${userId === undefined ? '' : 'WHERE user_id = ?'}
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
`).get(...(userId === undefined ? [] : [userId])) as { count: number }
return row.count
}
@@ -151,18 +165,23 @@ export function countBackupJobs(userId?: number): number {
export function listBackupJobsPage(
page = 1,
pageSize = 10,
- userId?: number
+ userId?: number,
+ includeHidden = false
): { 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 total = countBackupJobs(userId, includeHidden)
+ const where = [
+ ...(userId === undefined ? [] : ['user_id = ?']),
+ ...(includeHidden ? [] : ['hidden = 0'])
+ ]
const rows = getDb().prepare(`
SELECT id, user_id, type, status, progress, total, message, payload_json,
- output_path, error, created_at, updated_at, finished_at
+ output_path, error, created_at, updated_at, hidden, finished_at
FROM backup_jobs
- ${userId === undefined ? '' : 'WHERE user_id = ?'}
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(...(userId === undefined ? [limit, offset] : [userId, limit, offset])) as unknown as BackupJobRow[]
@@ -173,7 +192,7 @@ 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, updated_at, finished_at
+ output_path, error, created_at, updated_at, hidden, finished_at
FROM backup_jobs
WHERE user_id = ?
ORDER BY created_at DESC
@@ -201,8 +220,8 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void {
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, updated_at, finished_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const job of preserved) {
insert.run(
@@ -218,6 +237,7 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void {
job.error,
job.createdAt,
job.updatedAt,
+ job.hidden ? 1 : 0,
job.finishedAt
)
}
@@ -305,6 +325,34 @@ export function updateBackupJobPayload(
`).run(JSON.stringify(payload), id)
}
+export function setBackupJobHidden(
+ id: string,
+ userId: number,
+ hidden: boolean
+): boolean {
+ ensureBackupSchema()
+ const result = getDb().prepare(`
+ UPDATE backup_jobs
+ SET hidden = ?
+ WHERE id = ? AND user_id = ?
+ `).run(hidden ? 1 : 0, id, userId)
+ return Number(result.changes ?? 0) > 0
+}
+
+export function setAllFinishedBackupJobsHidden(
+ userId: number,
+ hidden: boolean
+): number {
+ ensureBackupSchema()
+ const result = getDb().prepare(`
+ UPDATE backup_jobs
+ SET hidden = ?
+ WHERE user_id = ?
+ AND status IN ('done', 'failed')
+ `).run(hidden ? 1 : 0, userId)
+ return Number(result.changes ?? 0)
+}
+
export function markJobRunning(id: string, message?: string): void {
updateBackupJob(id, {
status: 'running',
diff --git a/server/utils/backup/schema.ts b/server/utils/backup/schema.ts
index b850c80..834c255 100644
--- a/server/utils/backup/schema.ts
+++ b/server/utils/backup/schema.ts
@@ -25,11 +25,12 @@ function migrateBackupJobTypes(): void {
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
+ hidden INTEGER NOT NULL DEFAULT 0,
finished_at TEXT
);
INSERT INTO backup_jobs_new
- (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, finished_at)
- SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, COALESCE(updated_at, created_at), finished_at
+ (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, updated_at, hidden, finished_at)
+ SELECT id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, COALESCE(updated_at, created_at), hidden, finished_at
FROM backup_jobs;
DROP TABLE backup_jobs;
ALTER TABLE backup_jobs_new RENAME TO backup_jobs;
@@ -55,6 +56,7 @@ export function ensureBackupSchema(): void {
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
+ hidden INTEGER NOT NULL DEFAULT 0,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at
@@ -70,6 +72,9 @@ export function ensureBackupSchema(): void {
if (!columns.some(column => column.name === 'updated_at')) {
getDb().exec('ALTER TABLE backup_jobs ADD COLUMN updated_at TEXT')
}
+ if (!columns.some(column => column.name === 'hidden')) {
+ getDb().exec('ALTER TABLE backup_jobs ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0')
+ }
getDb().exec(`
CREATE INDEX IF NOT EXISTS idx_backup_jobs_user_created
ON backup_jobs(user_id, created_at DESC);
diff --git a/server/utils/env.ts b/server/utils/env.ts
index 2648c0f..fc506d8 100644
--- a/server/utils/env.ts
+++ b/server/utils/env.ts
@@ -26,6 +26,7 @@ import {
isAllowRegistration
} from './db'
import { getUploadPolicySettingsPayload } from './upload-policy'
+import { resolveRuntimeAppVersion } from './app-version'
export type FileBedAccessMode = 'public' | 'token'
@@ -806,7 +807,7 @@ export function getSettingsPayload(event: H3Event) {
const effectiveVideoBaseUrl = getVideoBaseUrl(event)
const effectiveFileBaseUrl = getFileBaseUrl(event)
const autoDeleteDays = getAutoDeleteDays(event)
- const appVersion = config.appVersion as string
+ const appVersion = resolveRuntimeAppVersion(String(config.appVersion ?? ''))
const domainSeparation = isDomainSeparationActive(event)
const hideFolderInUrl = isHideFolderInUrl(event)
const hideFolderInUrlSource = getHideFolderInUrlSource()
diff --git a/server/utils/site-sync/chunks.ts b/server/utils/site-sync/chunks.ts
new file mode 100644
index 0000000..ebb26d0
--- /dev/null
+++ b/server/utils/site-sync/chunks.ts
@@ -0,0 +1,350 @@
+import { createHash } from 'node:crypto'
+import { promises as fs } from 'node:fs'
+import { join } from 'node:path'
+import { getDataDir } from '../data-dir'
+import { findUserByUsername } from '../db'
+import { getBackendById } from '../storage/resolver'
+import { putLocalFileAtomic } from '../storage/local'
+import type { StoredImageMeta } from '../storage/types'
+import { recordSiteSyncArtifact } from './artifacts'
+
+export const SITE_SYNC_CHUNK_SIZE = 4 * 1024 * 1024
+const CHUNK_ROOT = '.pichost-tmp/site-sync-chunks'
+
+export interface SiteSyncChunkMetadata {
+ jobId: string
+ mediaKey: string
+ key: string
+ totalSize: number
+ totalChunks: number
+ ownerUsername: string | null
+ originalName: string
+ uploadedAt: string
+ contentType: string
+ mediaType: StoredImageMeta['mediaType']
+ conflictMode: 'skip' | 'overwrite'
+ backendId: string
+ updatedAt: string
+}
+
+interface StoredSession extends SiteSyncChunkMetadata {
+ created: boolean
+}
+
+function uploadIdFor(jobId: string, key: string): string {
+ return createHash('sha256')
+ .update(jobId)
+ .update('\0')
+ .update(key)
+ .digest('hex')
+}
+
+function sessionDir(uploadId: string): string {
+ return join(getDataDir(), CHUNK_ROOT, uploadId)
+}
+
+function metadataPath(uploadId: string): string {
+ return join(sessionDir(uploadId), 'session.json')
+}
+
+function chunkPath(uploadId: string, index: number): string {
+ return join(sessionDir(uploadId), `chunk-${String(index).padStart(6, '0')}.part`)
+}
+
+async function readSession(uploadId: string): Promise {
+ try {
+ const raw = await fs.readFile(metadataPath(uploadId), 'utf8')
+ return JSON.parse(raw) as StoredSession
+ } catch {
+ return null
+ }
+}
+
+async function writeSession(
+ uploadId: string,
+ session: StoredSession
+): Promise {
+ const path = metadataPath(uploadId)
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`
+ await fs.writeFile(temp, JSON.stringify(session))
+ await fs.rename(temp, path)
+}
+
+export async function getSiteSyncChunkStatus(input: {
+ jobId: string
+ key: string
+}): Promise<{
+ exists: boolean
+ complete: boolean
+ received: number[]
+ totalChunks: number
+ totalSize: number
+}> {
+ const uploadId = uploadIdFor(input.jobId, input.key)
+ const session = await readSession(uploadId)
+ if (!session) {
+ return {
+ exists: false,
+ complete: false,
+ received: [],
+ totalChunks: 0,
+ totalSize: 0
+ }
+ }
+ const received: number[] = []
+ for (let index = 0; index < session.totalChunks; index++) {
+ try {
+ const stat = await fs.stat(chunkPath(uploadId, index))
+ if (stat.isFile()) received.push(index)
+ } catch {
+ // Missing chunks are exactly what the caller needs to send.
+ }
+ }
+ return {
+ exists: true,
+ complete: received.length === session.totalChunks,
+ received,
+ totalChunks: session.totalChunks,
+ totalSize: session.totalSize
+ }
+}
+
+async function ensureSession(
+ metadata: SiteSyncChunkMetadata
+): Promise<{ uploadId: string, session: StoredSession }> {
+ const uploadId = uploadIdFor(metadata.jobId, metadata.key)
+ const existing = await readSession(uploadId)
+ if (existing) {
+ if (
+ existing.totalSize !== metadata.totalSize
+ || existing.totalChunks !== metadata.totalChunks
+ || existing.mediaKey !== metadata.mediaKey
+ ) {
+ throw new Error('分块上传会话参数不一致')
+ }
+ return { uploadId, session: existing }
+ }
+
+ const backend = await getBackendById(metadata.backendId)
+ if (!backend) throw new Error('分块上传存储后端不存在')
+ const session: StoredSession = {
+ ...metadata,
+ created: !await backend.head(metadata.key),
+ updatedAt: new Date().toISOString()
+ }
+ await fs.mkdir(sessionDir(uploadId), { recursive: true })
+ await writeSession(uploadId, session)
+ return { uploadId, session }
+}
+
+async function writeFileAtomic(path: string, bytes: Uint8Array): Promise {
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`
+ await fs.writeFile(temp, bytes)
+ await fs.rename(temp, path)
+}
+
+export async function writeSiteSyncChunk(input: {
+ metadata: SiteSyncChunkMetadata
+ index: number
+ bytes: Uint8Array
+}): Promise<{
+ received: number
+ totalChunks: number
+ complete: boolean
+}> {
+ const { uploadId, session } = await ensureSession(input.metadata)
+ if (
+ !Number.isInteger(input.index)
+ || input.index < 0
+ || input.index >= session.totalChunks
+ ) {
+ throw new Error('分块序号无效')
+ }
+ const start = input.index * SITE_SYNC_CHUNK_SIZE
+ const expectedSize = Math.min(
+ SITE_SYNC_CHUNK_SIZE,
+ session.totalSize - start
+ )
+ if (input.bytes.length !== expectedSize) {
+ throw new Error(`分块大小不一致:预期 ${expectedSize},实际 ${input.bytes.length}`)
+ }
+
+ await writeFileAtomic(chunkPath(uploadId, input.index), input.bytes)
+ session.updatedAt = new Date().toISOString()
+ await writeSession(uploadId, session)
+ let received = 0
+ for (let index = 0; index < session.totalChunks; index++) {
+ try {
+ await fs.access(chunkPath(uploadId, index))
+ received++
+ } catch {
+ // Keep counting.
+ }
+ }
+ return {
+ received,
+ totalChunks: session.totalChunks,
+ complete: received === session.totalChunks
+ }
+}
+
+async function concatenateChunks(
+ uploadId: string,
+ session: StoredSession
+): Promise {
+ const outputPath = join(sessionDir(uploadId), 'assembled.part')
+ const output = await fs.open(outputPath, 'w')
+ try {
+ for (let index = 0; index < session.totalChunks; index++) {
+ const input = await fs.open(chunkPath(uploadId, index), 'r')
+ try {
+ const stat = await input.stat()
+ let offset = 0
+ while (offset < stat.size) {
+ const length = Math.min(1024 * 1024, stat.size - offset)
+ const buffer = Buffer.allocUnsafe(length)
+ const { bytesRead } = await input.read(buffer, 0, length, offset)
+ if (!bytesRead) break
+ await output.write(buffer, 0, bytesRead)
+ offset += bytesRead
+ }
+ } finally {
+ await input.close()
+ }
+ }
+ await output.sync()
+ } finally {
+ await output.close()
+ }
+ return outputPath
+}
+
+export async function finalizeSiteSyncChunk(input: {
+ jobId: string
+ key: string
+}): Promise<{ success: true, skipped: boolean }> {
+ const uploadId = uploadIdFor(input.jobId, input.key)
+ const session = await readSession(uploadId)
+ if (!session) throw new Error('分块上传会话不存在')
+ const status = await getSiteSyncChunkStatus(input)
+ if (!status.complete) {
+ throw new Error(`分块尚未上传完成:${status.received.length}/${session.totalChunks}`)
+ }
+
+ const backend = await getBackendById(session.backendId)
+ if (!backend) throw new Error('分块上传存储后端不存在')
+ const existing = await backend.head(session.key)
+ if (session.conflictMode === 'skip' && existing) {
+ await fs.rm(sessionDir(uploadId), { recursive: true, force: true })
+ return { success: true, skipped: true }
+ }
+
+ const assembledPath = await concatenateChunks(uploadId, session)
+ const stat = await fs.stat(assembledPath)
+ if (stat.size !== session.totalSize) {
+ throw new Error(`组装文件大小不一致:预期 ${session.totalSize},实际 ${stat.size}`)
+ }
+
+ recordSiteSyncArtifact({
+ jobId: session.jobId,
+ mediaKey: session.mediaKey,
+ key: session.key,
+ backendId: session.backendId,
+ created: session.created
+ })
+
+ const owner = session.ownerUsername
+ ? findUserByUsername(session.ownerUsername)
+ : null
+ const meta: StoredImageMeta = {
+ originalName: session.originalName,
+ uploadedAt: session.uploadedAt,
+ contentType: session.contentType,
+ size: session.totalSize,
+ userId: owner?.id ?? null,
+ mediaType: session.mediaType
+ }
+ if (backend.type === 'local') {
+ await putLocalFileAtomic(session.key, assembledPath)
+ } else {
+ const chunks: Buffer[] = []
+ for (let index = 0; index < session.totalChunks; index++) {
+ chunks.push(await fs.readFile(chunkPath(uploadId, index)))
+ }
+ await backend.put(session.key, Buffer.concat(chunks), meta)
+ }
+ await fs.rm(sessionDir(uploadId), { recursive: true, force: true })
+ return { success: true, skipped: false }
+}
+
+export async function cleanupSiteSyncChunkSessions(
+ jobId: string
+): Promise {
+ const root = join(getDataDir(), CHUNK_ROOT)
+ let entries
+ try {
+ entries = await fs.readdir(root, { withFileTypes: true })
+ } catch {
+ return 0
+ }
+ let removed = 0
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ const session = await readSession(entry.name)
+ if (session?.jobId !== jobId) continue
+ await fs.rm(sessionDir(entry.name), { recursive: true, force: true })
+ removed++
+ }
+ return removed
+}
+
+export async function cleanupExpiredSiteSyncChunkSessions(
+ maxAgeMs = 24 * 60 * 60 * 1000
+): Promise {
+ const root = join(getDataDir(), CHUNK_ROOT)
+ let entries
+ try {
+ entries = await fs.readdir(root, { withFileTypes: true })
+ } catch {
+ return 0
+ }
+ const cutoff = Date.now() - maxAgeMs
+ let removed = 0
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue
+ const session = await readSession(entry.name)
+ if (!session || new Date(session.updatedAt).getTime() > cutoff) continue
+ await fs.rm(sessionDir(entry.name), { recursive: true, force: true })
+ removed++
+ }
+ return removed
+}
+
+export async function hasSiteSyncChunkSession(
+ jobId: string,
+ key: string
+): Promise {
+ return Boolean(await readSession(uploadIdFor(jobId, key)))
+}
+
+export function isSiteSyncChunkMetadata(
+ value: unknown
+): value is SiteSyncChunkMetadata {
+ if (!value || typeof value !== 'object') return false
+ const item = value as Record
+ return typeof item.jobId === 'string'
+ && typeof item.mediaKey === 'string'
+ && typeof item.key === 'string'
+ && Number.isInteger(item.totalSize)
+ && Number(item.totalSize) > 0
+ && Number.isInteger(item.totalChunks)
+ && Number(item.totalChunks) > 0
+ && typeof item.originalName === 'string'
+ && typeof item.uploadedAt === 'string'
+ && typeof item.contentType === 'string'
+ && (item.mediaType === 'image'
+ || item.mediaType === 'video'
+ || item.mediaType === 'file')
+ && (item.conflictMode === 'skip' || item.conflictMode === 'overwrite')
+ && typeof item.backendId === 'string'
+}
diff --git a/server/utils/site-sync/runner.ts b/server/utils/site-sync/runner.ts
index 05bcedf..de4b052 100644
--- a/server/utils/site-sync/runner.ts
+++ b/server/utils/site-sync/runner.ts
@@ -41,6 +41,10 @@ import {
completeSiteSyncMediaArtifacts,
recordSiteSyncArtifact
} from './artifacts'
+import {
+ SITE_SYNC_CHUNK_SIZE,
+ type SiteSyncChunkMetadata
+} from './chunks'
import { toSiteSyncMediaTransferWithTags } from './manifest'
import type {
SiteSyncDirection,
@@ -69,7 +73,7 @@ export function registerSiteSyncToken(jobId: string, token: string): void {
const SITE_SYNC_CONNECT_TIMEOUT_MS = 3 * 1000
const SITE_SYNC_RESPONSE_IDLE_TIMEOUT_MS = 3 * 1000
-const SITE_SYNC_UPLOAD_HEADERS_TIMEOUT_MS = 10 * 60 * 1000
+const SITE_SYNC_CHUNK_RESPONSE_TIMEOUT_MS = 15 * 1000
interface SiteSyncAgents {
upload: Agent
@@ -81,7 +85,7 @@ const agents = (globalThis as typeof globalThis & {
}).__pichostSiteSyncAgents ?? {
upload: new Agent({
connect: { timeout: SITE_SYNC_CONNECT_TIMEOUT_MS },
- headersTimeout: SITE_SYNC_UPLOAD_HEADERS_TIMEOUT_MS,
+ headersTimeout: SITE_SYNC_CHUNK_RESPONSE_TIMEOUT_MS,
bodyTimeout: SITE_SYNC_RESPONSE_IDLE_TIMEOUT_MS
}),
object: new Agent({
@@ -148,7 +152,7 @@ const SITE_SYNC_MAX_ATTEMPTS = 3
export interface SiteSyncRetryStatus {
attempt: number
maxAttempts: number
- phase: 'attempt' | 'countdown' | 'retry'
+ phase: 'attempt' | 'countdown' | 'active' | 'retry'
delayMs?: number
remainingMs?: number
error?: Error
@@ -168,6 +172,11 @@ function formatRetryStatus(
)
return `${step}(第 ${status.attempt}/${status.maxAttempts} 次)· 连接倒计时 ${seconds}s`
}
+ if (status.phase === 'active') {
+ return step.startsWith('上传对象')
+ ? `${step} · 已连接,上传中`
+ : `${step} · 连接已建立,等待响应`
+ }
if ((status.delayMs ?? 0) < 1000) {
return `${step}第 ${status.attempt}/${status.maxAttempts} 次无响应,即将重试:${status.error?.message ?? '连接失败'}`
}
@@ -213,6 +222,12 @@ export async function retrySiteSyncOperation(
if (remainingMs <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = null
+ onStatus({
+ attempt,
+ maxAttempts: SITE_SYNC_MAX_ATTEMPTS,
+ phase: 'active',
+ remainingMs: 0
+ })
return
}
onStatus({
@@ -385,6 +400,72 @@ export async function fetchSiteSyncJson(
)
}
+async function uploadSiteSyncChunk(input: {
+ jobId: string
+ targetBaseUrl: string
+ token: string
+ metadata: SiteSyncChunkMetadata
+ index: number
+ bytes: Uint8Array
+ uploadedChunks: number
+ onProgress?: (message: string) => void
+}): Promise<{
+ received: number
+ totalChunks: number
+ complete: boolean
+}> {
+ const controller = new AbortController()
+ const jobSignal = getJobAbortSignal(input.jobId)
+ const onAbort = () => controller.abort(createJobCancelledError())
+ jobSignal.addEventListener('abort', onAbort, { once: true })
+ const timeout = setTimeout(() => {
+ controller.abort(new DOMException(
+ 'Chunk upload made no progress for 15 seconds',
+ 'TimeoutError'
+ ))
+ }, SITE_SYNC_CHUNK_RESPONSE_TIMEOUT_MS)
+ const startedAt = Date.now()
+ const fileName = basename(input.metadata.key)
+ const updateUploadTime = () => {
+ const seconds = Math.floor((Date.now() - startedAt) / 1000)
+ input.onProgress?.(
+ `上传对象 ${fileName} · 已连接,上传中 · 已上传 ${input.uploadedChunks}/${input.metadata.totalChunks} 块 · 等待 ${seconds}s`
+ )
+ }
+ updateUploadTime()
+ const ticker = setInterval(updateUploadTime, 1000)
+
+ try {
+ const encoded = Buffer.from(JSON.stringify(input.metadata))
+ .toString('base64url')
+ const response = await undiciFetch(
+ targetUrl(input.targetBaseUrl, '/api/site-sync/chunk'),
+ {
+ method: 'POST',
+ body: input.bytes,
+ headers: {
+ 'Auth-Token': input.token,
+ 'Content-Type': 'application/octet-stream',
+ 'X-PicHost-Chunk-Metadata': encoded,
+ 'X-PicHost-Chunk-Index': String(input.index)
+ },
+ signal: AbortSignal.any([jobSignal, controller.signal]),
+ dispatcher: agents.upload
+ }
+ )
+ if (!response.ok) throw await responseError(response)
+ return await response.json() as {
+ received: number
+ totalChunks: number
+ complete: boolean
+ }
+ } finally {
+ clearTimeout(timeout)
+ clearInterval(ticker)
+ jobSignal.removeEventListener('abort', onAbort)
+ }
+}
+
async function uploadObject(
jobId: string,
options: SiteSyncOptions,
@@ -392,25 +473,107 @@ async function uploadObject(
backend: StorageBackend,
row: ImageIndexRow,
ownerUsername: string | null,
- key: string
+ key: string,
+ onProgress?: (message: string) => void
): Promise {
const stored = await backend.head(key)
if (!stored) throw new Error(`源文件不存在:${key}`)
+ const isPrimary = key === row.key
+ const originalName = isPrimary ? row.original_name : basename(key)
+ const contentType = isPrimary ? row.content_type : contentTypeFromKey(key)
+ const mediaType = isPrimary
+ ? row.media_type
+ : key.startsWith('videos/')
+ ? 'video'
+ : key.startsWith('files/')
+ ? 'file'
+ : 'image'
+
+ if (stored.size > SITE_SYNC_CHUNK_SIZE) {
+ const totalChunks = Math.ceil(stored.size / SITE_SYNC_CHUNK_SIZE)
+ const metadata: SiteSyncChunkMetadata = {
+ jobId,
+ mediaKey: row.key,
+ key,
+ totalSize: stored.size,
+ totalChunks,
+ ownerUsername,
+ originalName,
+ uploadedAt: row.uploaded_at,
+ contentType,
+ mediaType,
+ conflictMode: options.conflictMode,
+ backendId: backend.id,
+ updatedAt: new Date().toISOString()
+ }
+ const status = await fetchSiteSyncJson<{
+ received: number[]
+ totalChunks: number
+ }>(
+ targetUrl(
+ options.targetBaseUrl,
+ `/api/site-sync/chunk-status?jobId=${encodeURIComponent(jobId)}&key=${encodeURIComponent(key)}`
+ ),
+ token,
+ undefined,
+ getJobAbortSignal(jobId),
+ (onStatus) => {
+ onProgress?.(
+ formatRetryStatus(`检查对象 ${basename(key)} 分块进度`, onStatus)
+ )
+ }
+ )
+ const received = new Set(status.received)
+ for (let index = 0; index < totalChunks; index++) {
+ if (received.has(index)) continue
+ throwIfJobCancellationRequested(jobId)
+ const start = index * SITE_SYNC_CHUNK_SIZE
+ const end = Math.min(stored.size, start + SITE_SYNC_CHUNK_SIZE) - 1
+ const bytes = await streamToBuffer(
+ await backend.createStream(key, { start, end }),
+ getJobAbortSignal(jobId)
+ )
+ const result = await uploadSiteSyncChunk({
+ jobId,
+ targetBaseUrl: options.targetBaseUrl,
+ token,
+ metadata,
+ index,
+ bytes,
+ uploadedChunks: received.size,
+ onProgress
+ })
+ received.add(index)
+ onProgress?.(
+ `上传对象 ${basename(key)} · 已连接,上传中 · 已上传 ${result.received}/${result.totalChunks} 块`
+ )
+ }
+ await fetchSiteSyncJsonOnce<{ success: boolean }>(
+ targetUrl(options.targetBaseUrl, '/api/site-sync/chunk-finalize'),
+ token,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jobId, key }),
+ signal: getJobAbortSignal(jobId)
+ }
+ )
+ return
+ }
+
const bytes = await streamToBuffer(
await backend.createStream(key),
getJobAbortSignal(jobId)
)
throwIfJobCancellationRequested(jobId)
const form = new UndiciFormData()
- const isPrimary = key === row.key
form.append('jobId', jobId)
form.append('mediaKey', row.key)
form.append('key', key)
form.append('conflictMode', options.conflictMode)
form.append('ownerUsername', ownerUsername ?? '')
- form.append('originalName', isPrimary ? row.original_name : basename(key))
+ form.append('originalName', originalName)
form.append('uploadedAt', row.uploaded_at)
- const contentType = isPrimary ? row.content_type : contentTypeFromKey(key)
form.append('contentType', contentType)
form.append(
'file',
@@ -640,7 +803,10 @@ async function runSiteSyncPull(
() => processBundleOnce(
bundle,
isRetry,
- (value) => { step = value }
+ (value) => {
+ step = value
+ updateLiveStatus(value)
+ }
),
jobSignal,
status => updateLiveStatus(step, status)
@@ -934,7 +1100,8 @@ export async function runSiteSyncJob(
backend,
row,
ownerUsername,
- key
+ key,
+ message => setStep(message)
)
}
@@ -968,7 +1135,10 @@ export async function runSiteSyncJob(
() => processPlanOnce(
plan,
isRetry,
- (value) => { step = value }
+ (value) => {
+ step = value
+ updateLiveStatus(value)
+ }
),
jobSignal,
status => updateLiveStatus(step, status)
diff --git a/server/utils/storage/local.ts b/server/utils/storage/local.ts
index d3dec66..e945e3f 100644
--- a/server/utils/storage/local.ts
+++ b/server/utils/storage/local.ts
@@ -151,3 +151,27 @@ export async function cleanupLocalAtomicTempFiles(
}
return removed
}
+
+/** Move an already-complete file into the storage tree without loading it into memory. */
+export async function putLocalFileAtomic(
+ key: string,
+ sourcePath: string
+): Promise {
+ const filePath = keyToFilePath(key)
+ await fs.mkdir(dirname(filePath), { recursive: true })
+ try {
+ await fs.rename(sourcePath, filePath)
+ } catch (error) {
+ if (
+ error
+ && typeof error === 'object'
+ && 'code' in error
+ && error.code === 'EXDEV'
+ ) {
+ await fs.copyFile(sourcePath, filePath)
+ await fs.rm(sourcePath, { force: true })
+ return
+ }
+ throw error
+ }
+}