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
@@ -0,0 +1,173 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { getDb } from './db'
|
||||
import {
|
||||
deleteImageIndex,
|
||||
listImageIndexRowsForUser
|
||||
} from './image-index'
|
||||
import { getBackendForKey } from './storage/resolver'
|
||||
import type { StorageBackend } from './storage/types'
|
||||
import { cancelTranscode } from './transcode'
|
||||
import { collectMediaAssetKeys } from './media-assets'
|
||||
import {
|
||||
deleteBackupJobsForUser,
|
||||
getActiveBackupJob,
|
||||
listAllBackupJobsForUser,
|
||||
type BackupJob
|
||||
} from './backup/jobs'
|
||||
import {
|
||||
getBackupsDir,
|
||||
getBackupStagingDir,
|
||||
isSafeBackupFilename
|
||||
} from './backup/paths'
|
||||
|
||||
const activeUsers = new Set<number>()
|
||||
|
||||
export interface ClearUserStorageResult {
|
||||
deletedMedia: number
|
||||
deletedObjects: number
|
||||
deletedTags: number
|
||||
deletedLogs: number
|
||||
clearedCacheDirs: number
|
||||
deletedJobs: number
|
||||
failed: Array<{ key: string, message: string }>
|
||||
}
|
||||
|
||||
async function deleteObject(
|
||||
backend: StorageBackend,
|
||||
key: string
|
||||
): Promise<boolean> {
|
||||
if (!await backend.head(key)) return false
|
||||
await backend.delete(key)
|
||||
return true
|
||||
}
|
||||
|
||||
async function clearHlsDownloadCache(userId: number): Promise<number> {
|
||||
let removed = 0
|
||||
const prefix = `pichost-hls-download-${userId}-`
|
||||
try {
|
||||
const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue
|
||||
await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true })
|
||||
removed++
|
||||
}
|
||||
} catch {
|
||||
// Cache cleanup failure does not affect the media cleanup result.
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async function removeBackupJobArtifacts(job: BackupJob): Promise<void> {
|
||||
await fs.rm(getBackupStagingDir(job.id), { recursive: true, force: true }).catch(() => {})
|
||||
|
||||
const paths = [
|
||||
job.outputPath,
|
||||
typeof job.payload.archivePath === 'string' ? job.payload.archivePath : null
|
||||
]
|
||||
for (const path of paths) {
|
||||
if (!path || !isSafeBackupFilename(basename(path))) continue
|
||||
if (resolve(dirname(path)) !== resolve(getBackupsDir())) continue
|
||||
await fs.rm(path, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearUserStorage(userId: number): Promise<ClearUserStorageResult> {
|
||||
if (activeUsers.has(userId)) {
|
||||
throw new Error('USER_STORAGE_CLEAR_IN_PROGRESS')
|
||||
}
|
||||
activeUsers.add(userId)
|
||||
|
||||
const result: ClearUserStorageResult = {
|
||||
deletedMedia: 0,
|
||||
deletedObjects: 0,
|
||||
deletedTags: 0,
|
||||
deletedLogs: 0,
|
||||
clearedCacheDirs: 0,
|
||||
deletedJobs: 0,
|
||||
failed: []
|
||||
}
|
||||
|
||||
try {
|
||||
if (getActiveBackupJob(userId)) {
|
||||
throw new Error('USER_STORAGE_CLEAR_ACTIVE_JOB')
|
||||
}
|
||||
|
||||
const backupJobs = listAllBackupJobsForUser(userId)
|
||||
const rows = listImageIndexRowsForUser(userId)
|
||||
for (const row of rows) {
|
||||
try {
|
||||
cancelTranscode(row.key)
|
||||
const backend = await getBackendForKey(row.key)
|
||||
let keys: string[]
|
||||
try {
|
||||
keys = await collectMediaAssetKeys(row, backend)
|
||||
} catch {
|
||||
keys = [
|
||||
...(Number(row.source_deleted ?? 0) !== 1 ? [row.key] : []),
|
||||
...(row.thumb_key ? [row.thumb_key] : []),
|
||||
...(row.hls_key ? [row.hls_key] : [])
|
||||
]
|
||||
}
|
||||
|
||||
for (const key of new Set(keys)) {
|
||||
if (await deleteObject(backend, key)) result.deletedObjects++
|
||||
}
|
||||
deleteImageIndex(row.key)
|
||||
result.deletedMedia++
|
||||
} catch (error) {
|
||||
result.failed.push({
|
||||
key: row.key,
|
||||
message: error instanceof Error ? error.message : '删除失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
DELETE FROM image_tags
|
||||
WHERE image_key NOT IN (SELECT key FROM images)
|
||||
`).run()
|
||||
|
||||
if (!result.failed.length) {
|
||||
const tagCount = db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?')
|
||||
.get(userId) as { count: number }
|
||||
const logCount = db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?')
|
||||
.get(userId) as { count: number }
|
||||
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(`
|
||||
DELETE FROM image_tags
|
||||
WHERE tag_id IN (SELECT id FROM tags WHERE user_id = ?)
|
||||
`).run(userId)
|
||||
db.prepare('DELETE FROM tags WHERE user_id = ?').run(userId)
|
||||
db.prepare('DELETE FROM activity_logs WHERE user_id = ?').run(userId)
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET auto_delete_days = 0, auto_delete_enabled_at = NULL
|
||||
WHERE id = ?
|
||||
`).run(userId)
|
||||
db.exec('COMMIT')
|
||||
result.deletedTags = Number(tagCount.count)
|
||||
result.deletedLogs = Number(logCount.count)
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.failed.length) {
|
||||
for (const job of backupJobs) {
|
||||
await removeBackupJobArtifacts(job)
|
||||
}
|
||||
result.deletedJobs = deleteBackupJobsForUser(userId)
|
||||
}
|
||||
|
||||
result.clearedCacheDirs = await clearHlsDownloadCache(userId)
|
||||
return result
|
||||
} finally {
|
||||
activeUsers.delete(userId)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user