import fs from "node:fs"; import path from "node:path"; import { dbGlobal } from "drizzle-pkg/lib/db"; import { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content"; import { and, count, desc, eq, inArray, isNotNull, isNull, lte, not, notExists, or, sql } from "drizzle-orm"; import { MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF, MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF, POST_MEDIA_PUBLIC_PREFIX, RELATIVE_ASSETS_DIR, } from "#server/constants/media"; import { mergePostMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls"; import { nextIntegerId } from "#server/utils/sqlite-id"; const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000; const AFTER_DEREF_MS = MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF * 3600 * 1000; function assetsBaseDir(): string { return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR); } function isPathUnderAssetsDir(resolvedFile: string): boolean { const base = assetsBaseDir() + path.sep; return resolvedFile === assetsBaseDir() || resolvedFile.startsWith(base); } /** 删除主文件及 variantsJson 中列出的、位于 assets 目录下的相对路径 */ async function unlinkMediaFiles(storageKey: string, variantsJson: string | null): Promise { const main = path.resolve(assetsBaseDir(), storageKey); if (!isPathUnderAssetsDir(main)) { return; } try { await fs.promises.unlink(main); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") { throw e; } } const rels = parseVariantRelativePaths(variantsJson); for (const rel of rels) { if (rel.includes("..") || path.isAbsolute(rel)) { continue; } const full = path.resolve(assetsBaseDir(), rel); if (!isPathUnderAssetsDir(full)) { continue; } try { await fs.promises.unlink(full); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") { throw e; } } } } function parseVariantRelativePaths(raw: string | null): string[] { if (!raw) { return []; } try { const j = JSON.parse(raw) as unknown; if (!Array.isArray(j)) { return []; } return j.filter((x): x is string => typeof x === "string" && x.length > 0); } catch { return []; } } export function isAssetDeletable(row: { firstReferencedAt: Date | null; dereferencedAt: Date | null; createdAt: Date; }): boolean { const now = Date.now(); if (row.firstReferencedAt == null) { return row.createdAt.getTime() <= now - NEVER_REF_MS; } if (row.dereferencedAt == null) { return false; } return row.dereferencedAt.getTime() <= now - AFTER_DEREF_MS; } /** 孤儿资源「宽限结束、允许删除」的绝对时间;无法推算时返回 null */ export function computeOrphanGraceExpiresAt(row: { firstReferencedAt: Date | null; dereferencedAt: Date | null; createdAt: Date; }): Date | null { if (row.firstReferencedAt == null) { return new Date(row.createdAt.getTime() + NEVER_REF_MS); } if (row.dereferencedAt == null) { return null; } return new Date(row.dereferencedAt.getTime() + AFTER_DEREF_MS); } function orphanCondition() { return notExists( dbGlobal.select({ x: sql`1` }).from(mediaRefs).where(eq(mediaRefs.assetId, mediaAssets.id)), ); } function deletableTimeCondition(now: number) { const neverRefCutoff = new Date(now - NEVER_REF_MS); const afterDerefCutoff = new Date(now - AFTER_DEREF_MS); return or( and(isNull(mediaAssets.firstReferencedAt), lte(mediaAssets.createdAt, neverRefCutoff)), and( isNotNull(mediaAssets.firstReferencedAt), isNotNull(mediaAssets.dereferencedAt), lte(mediaAssets.dereferencedAt, afterDerefCutoff), ), ); } export async function insertMediaAssetRow(params: { userId: number; storageKey: string; mime: string; sizeBytes: number; sha256?: string | null; variantsJson?: string | null; }): Promise { const id = await nextIntegerId(mediaAssets, mediaAssets.id); await dbGlobal.insert(mediaAssets).values({ id, userId: params.userId, storageKey: params.storageKey, mime: params.mime, sizeBytes: params.sizeBytes, sha256: params.sha256 ?? null, variantsJson: params.variantsJson ?? null, }); return id; } export async function reconcileAssetTimestampsAfterRefChange(assetIds: number[]): Promise { const unique = [...new Set(assetIds)]; for (const id of unique) { const [{ c }] = await dbGlobal .select({ c: count() }) .from(mediaRefs) .where(eq(mediaRefs.assetId, id)); if (c > 0) { const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1); if (!row) { continue; } await dbGlobal .update(mediaAssets) .set({ firstReferencedAt: row.firstReferencedAt ?? new Date(), dereferencedAt: null, }) .where(eq(mediaAssets.id, id)); continue; } const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1); if (!row) { continue; } if (row.firstReferencedAt == null) { continue; } if (row.dereferencedAt != null) { continue; } await dbGlobal .update(mediaAssets) .set({ dereferencedAt: new Date() }) .where(eq(mediaAssets.id, id)); } } export async function syncPostMediaRefs( userId: number, postId: number, bodyMarkdown: string, coverUrl: string | null, ): Promise { const beforeRows = await dbGlobal .select({ assetId: mediaRefs.assetId }) .from(mediaRefs) .where(eq(mediaRefs.postId, postId)); const beforeIds = beforeRows.map((r) => r.assetId); await dbGlobal.delete(mediaRefs).where(eq(mediaRefs.postId, postId)); const urls = mergePostMediaUrls(bodyMarkdown, coverUrl); const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))]; let afterIds: number[] = []; if (keys.length > 0) { const assetRows = await dbGlobal .select({ id: mediaAssets.id }) .from(mediaAssets) .where(and(eq(mediaAssets.userId, userId), inArray(mediaAssets.storageKey, keys))); afterIds = assetRows.map((r) => r.id); if (afterIds.length > 0) { await dbGlobal .insert(mediaRefs) .values(afterIds.map((assetId) => ({ postId, assetId }))) .onConflictDoNothing(); } } await reconcileAssetTimestampsAfterRefChange([...new Set([...beforeIds, ...afterIds])]); } export async function listOrphanCandidatesForUser( userId: number, filter: "all" | "deletable" | "cooling", page: number, pageSize: number, ): Promise<{ items: Array<{ id: number; storageKey: string; publicUrl: string; sizeBytes: number; createdAt: Date; firstReferencedAt: Date | null; dereferencedAt: Date | null; graceExpiresAt: Date | null; state: "deletable" | "cooling"; }>; total: number; }> { const now = Date.now(); const base = and(eq(mediaAssets.userId, userId), orphanCondition()); let whereClause = filter === "all" ? base : filter === "deletable" ? and(base, deletableTimeCondition(now)) : and(base, not(deletableTimeCondition(now))); const [{ total: totalRaw }] = await dbGlobal.select({ total: count() }).from(mediaAssets).where(whereClause); const offset = Math.max(0, (page - 1) * pageSize); const rows = await dbGlobal .select({ id: mediaAssets.id, storageKey: mediaAssets.storageKey, sizeBytes: mediaAssets.sizeBytes, createdAt: mediaAssets.createdAt, firstReferencedAt: mediaAssets.firstReferencedAt, dereferencedAt: mediaAssets.dereferencedAt, }) .from(mediaAssets) .where(whereClause) .orderBy(desc(mediaAssets.createdAt), desc(mediaAssets.id)) .limit(pageSize) .offset(offset); const items = rows.map((row) => { const state: "deletable" | "cooling" = isAssetDeletable(row) ? "deletable" : "cooling"; return { id: row.id, storageKey: row.storageKey, publicUrl: `${POST_MEDIA_PUBLIC_PREFIX}${row.storageKey}`, sizeBytes: row.sizeBytes, createdAt: row.createdAt, firstReferencedAt: row.firstReferencedAt, dereferencedAt: row.dereferencedAt, graceExpiresAt: computeOrphanGraceExpiresAt(row), state, }; }); return { items, total: totalRaw }; } async function assertAssetDeletableOrThrow(row: typeof mediaAssets.$inferSelect): Promise { const [{ c }] = await dbGlobal .select({ c: count() }) .from(mediaRefs) .where(eq(mediaRefs.assetId, row.id)); if (c > 0) { throw createError({ statusCode: 400, statusMessage: "资源仍被文章引用,无法删除" }); } if (!isAssetDeletable(row)) { throw createError({ statusCode: 400, statusMessage: "资源尚在宽限期,暂不可删除" }); } } export async function deleteMediaAssetsForUser(userId: number, ids: number[]): Promise { if (ids.length === 0) { return 0; } for (const id of ids) { const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, id)).limit(1); if (!row || row.userId !== userId) { throw createError({ statusCode: 400, statusMessage: "资源不存在或不属于当前用户" }); } await assertAssetDeletableOrThrow(row); await unlinkMediaFiles(row.storageKey, row.variantsJson); await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, id)); } return ids.length; } export async function purgeAllDeletableOrphansGlobally(limit: number): Promise { const now = Date.now(); const whereClause = and(orphanCondition(), deletableTimeCondition(now)); const candidates = await dbGlobal .select() .from(mediaAssets) .where(whereClause) .orderBy(mediaAssets.id) .limit(limit); let deleted = 0; for (const row of candidates) { const [{ c }] = await dbGlobal .select({ c: count() }) .from(mediaRefs) .where(eq(mediaRefs.assetId, row.id)); if (c > 0) { continue; } if (!isAssetDeletable(row)) { continue; } await unlinkMediaFiles(row.storageKey, row.variantsJson); await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, row.id)); deleted += 1; } return deleted; }