diff --git a/server/service/media/index.ts b/server/service/media/index.ts new file mode 100644 index 0000000..17354b1 --- /dev/null +++ b/server/service/media/index.ts @@ -0,0 +1,328 @@ +import fs from "node:fs"; +import path from "node:path"; +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { mediaAssets, postMediaRefs } 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; +} + +function orphanCondition() { + return notExists( + dbGlobal.select({ x: sql`1` }).from(postMediaRefs).where(eq(postMediaRefs.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(postMediaRefs) + .where(eq(postMediaRefs.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: postMediaRefs.assetId }) + .from(postMediaRefs) + .where(eq(postMediaRefs.postId, postId)); + const beforeIds = beforeRows.map((r) => r.assetId); + + await dbGlobal.delete(postMediaRefs).where(eq(postMediaRefs.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(postMediaRefs) + .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; + 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, + state, + }; + }); + + return { items, total: totalRaw }; +} + +async function assertAssetDeletableOrThrow(row: typeof mediaAssets.$inferSelect): Promise { + const [{ c }] = await dbGlobal + .select({ c: count() }) + .from(postMediaRefs) + .where(eq(postMediaRefs.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(postMediaRefs) + .where(eq(postMediaRefs.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; +}