import { dbGlobal } from "drizzle-pkg/lib/db"; import { users } from "drizzle-pkg/lib/schema/auth"; import { mediaRefs, posts } from "drizzle-pkg/lib/schema/content"; import { eq, inArray } from "drizzle-orm"; import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs"; import { extractMediaUrlsFromMarkdown, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls"; import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public"; export type MediaRefContextDto = { ownerType: string; ownerId: number; /** 人类可读,如文章标题与位置、资料中的头像/简介 */ label: string; /** 可跳转的站内路径;无权限或私密时为空 */ href: string | null; }; function extractUrls(markdown: string | null | undefined): string[] { return extractMediaUrlsFromMarkdown(markdown ?? "", { allowedAssetOrigins: allowedOriginsFromSitePublicEnv(), }); } function postUsageParts(bodyMarkdown: string, coverUrl: string | null, storageKey: string): string[] { const parts: string[] = []; if (publicAssetUrlToStorageKey(coverUrl ?? "") === storageKey) { parts.push("封面"); } if (extractUrls(bodyMarkdown).some((u) => publicAssetUrlToStorageKey(u) === storageKey)) { parts.push("正文 Markdown"); } return parts; } function profileUsageParts(avatar: string | null, bioMarkdown: string | null, storageKey: string): string[] { const parts: string[] = []; if (publicAssetUrlToStorageKey(avatar ?? "") === storageKey) { parts.push("头像"); } if (extractUrls(bioMarkdown).some((u) => publicAssetUrlToStorageKey(u) === storageKey)) { parts.push("简介 Markdown"); } return parts; } function postHref( post: { id: number; userId: number; visibility: string; slug: string; authorPublicSlug: string | null; }, viewerUserId: number, ): string | null { if (post.userId === viewerUserId) { return `/me/posts/${post.id}`; } if (post.visibility === "public" && post.authorPublicSlug) { return `/@${post.authorPublicSlug}/posts/${encodeURIComponent(post.slug)}`; } return null; } /** * 为一批资源构建引用上下文(文章 / 个人资料),用于媒体库与存储审计展示。 */ export async function buildRefContextsForAssets( assets: Array<{ id: number; storageKey: string }>, viewer: { userId: number; role: string }, ): Promise> { const map = new Map(); if (assets.length === 0) { return map; } const assetIds = assets.map((a) => a.id); const idToKey = new Map(assets.map((a) => [a.id, a.storageKey])); const refRows = await dbGlobal .select({ assetId: mediaRefs.assetId, ownerType: mediaRefs.ownerType, ownerId: mediaRefs.ownerId, }) .from(mediaRefs) .where(inArray(mediaRefs.assetId, assetIds)); const refsByAsset = new Map(); for (const r of refRows) { const list = refsByAsset.get(r.assetId) ?? []; list.push({ ownerType: r.ownerType, ownerId: r.ownerId }); refsByAsset.set(r.assetId, list); } const postIds = [...new Set(refRows.filter((r) => r.ownerType === MEDIA_REF_OWNER_POST).map((r) => r.ownerId))]; const profileUserIds = [ ...new Set(refRows.filter((r) => r.ownerType === MEDIA_REF_OWNER_PROFILE).map((r) => r.ownerId)), ]; const postMap = new Map< number, { id: number; userId: number; title: string; slug: string; visibility: string; bodyMarkdown: string; coverUrl: string | null; authorPublicSlug: string | null; } >(); if (postIds.length > 0) { const rows = await dbGlobal .select({ id: posts.id, userId: posts.userId, title: posts.title, slug: posts.slug, visibility: posts.visibility, bodyMarkdown: posts.bodyMarkdown, coverUrl: posts.coverUrl, authorPublicSlug: users.publicSlug, }) .from(posts) .innerJoin(users, eq(posts.userId, users.id)) .where(inArray(posts.id, postIds)); for (const p of rows) { postMap.set(p.id, p); } } const userMap = new Map(); if (profileUserIds.length > 0) { const urows = await dbGlobal .select({ id: users.id, username: users.username, avatar: users.avatar, bioMarkdown: users.bioMarkdown, }) .from(users) .where(inArray(users.id, profileUserIds)); for (const u of urows) { userMap.set(u.id, u); } } for (const asset of assets) { const storageKey = idToKey.get(asset.id); if (!storageKey) { continue; } const refs = refsByAsset.get(asset.id) ?? []; const contexts: MediaRefContextDto[] = []; for (const r of refs) { if (r.ownerType === MEDIA_REF_OWNER_POST) { const p = postMap.get(r.ownerId); if (!p) { contexts.push({ ownerType: r.ownerType, ownerId: r.ownerId, label: `文章 #${r.ownerId}(记录已不存在)`, href: null, }); continue; } const usage = postUsageParts(p.bodyMarkdown, p.coverUrl, storageKey); const usageText = usage.length ? ` · ${usage.join("、")}` : ""; contexts.push({ ownerType: r.ownerType, ownerId: r.ownerId, label: `文章「${truncateTitle(p.title)}」${usageText}`, href: postHref( { id: p.id, userId: p.userId, visibility: p.visibility, slug: p.slug, authorPublicSlug: p.authorPublicSlug, }, viewer.userId, ), }); } else if (r.ownerType === MEDIA_REF_OWNER_PROFILE) { const u = userMap.get(r.ownerId); if (!u) { contexts.push({ ownerType: r.ownerType, ownerId: r.ownerId, label: `个人资料用户 #${r.ownerId}(记录已不存在)`, href: null, }); continue; } const slots = profileUsageParts(u.avatar, u.bioMarkdown, storageKey); const slotText = slots.length ? slots.join("、") : "图片"; const href = viewer.userId === u.id ? "/me/profile" : null; contexts.push({ ownerType: r.ownerType, ownerId: r.ownerId, label: `用户 @${u.username} 的个人资料 · ${slotText}`, href, }); } else { contexts.push({ ownerType: r.ownerType, ownerId: r.ownerId, label: `${r.ownerType} #${r.ownerId}`, href: null, }); } } map.set(asset.id, contexts); } return map; } function truncateTitle(title: string, max = 48): string { const t = title.trim(); if (t.length <= max) { return t; } return `${t.slice(0, max - 1)}…`; }