Browse Source
- Introduced user notes for media assets, allowing admins and users to add descriptions. - Enhanced media asset listing with reference contexts, providing links to related content. - Updated API endpoints to support note saving and retrieval. - Modified database schema to include user notes and related fields. - Improved search functionality to include user notes in media asset queries. Made-with: Cursortags/邮箱功能前置
17 changed files with 991 additions and 58 deletions
Binary file not shown.
@ -0,0 +1 @@ |
|||||
|
ALTER TABLE `media_assets` ADD `user_note` text; |
||||
@ -0,0 +1,35 @@ |
|||||
|
import { setMediaAssetUserNote } from "#server/service/media"; |
||||
|
import { requireAdmin } from "#server/utils/admin-guard"; |
||||
|
import { R } from "#server/utils/response"; |
||||
|
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; |
||||
|
import { getRequestIP } from "h3"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const admin = await requireAdmin(event); |
||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; |
||||
|
assertUnderRateLimit(`admin-media-asset-note:${ip}`, 60, 60_000); |
||||
|
|
||||
|
const idRaw = getRouterParam(event, "id"); |
||||
|
const assetId = Number(idRaw); |
||||
|
if (!Number.isInteger(assetId) || assetId < 1) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "无效的资源 id" }); |
||||
|
} |
||||
|
const body = await readBody<{ userNote?: unknown }>(event); |
||||
|
if (!body || typeof body !== "object" || !("userNote" in body)) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "请求体须包含 userNote(字符串或 null)" }); |
||||
|
} |
||||
|
const note = body.userNote; |
||||
|
if (note !== null && typeof note !== "string") { |
||||
|
throw createError({ statusCode: 400, statusMessage: "userNote 须为字符串或 null" }); |
||||
|
} |
||||
|
const resolved = note === null ? null : note.trim() === "" ? null : note.trim(); |
||||
|
|
||||
|
await setMediaAssetUserNote({ |
||||
|
assetId, |
||||
|
note: resolved, |
||||
|
actorUserId: admin.id, |
||||
|
actorIsAdmin: true, |
||||
|
}); |
||||
|
|
||||
|
return R.success({ ok: true }); |
||||
|
}); |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { deleteMediaAssetsForUser } from "#server/service/media"; |
||||
|
import { R } from "#server/utils/response"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const user = await event.context.auth.requireUser(); |
||||
|
const idRaw = getRouterParam(event, "id"); |
||||
|
const assetId = Number(idRaw); |
||||
|
if (!Number.isInteger(assetId) || assetId < 1) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "无效的资源 id" }); |
||||
|
} |
||||
|
|
||||
|
await deleteMediaAssetsForUser(user.id, [assetId]); |
||||
|
return R.success({ ok: true }); |
||||
|
}); |
||||
@ -0,0 +1,29 @@ |
|||||
|
import { setMediaAssetUserNote } from "#server/service/media"; |
||||
|
import { R } from "#server/utils/response"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const user = await event.context.auth.requireUser(); |
||||
|
const idRaw = getRouterParam(event, "id"); |
||||
|
const assetId = Number(idRaw); |
||||
|
if (!Number.isInteger(assetId) || assetId < 1) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "无效的资源 id" }); |
||||
|
} |
||||
|
const body = await readBody<{ userNote?: unknown }>(event); |
||||
|
if (!body || typeof body !== "object" || !("userNote" in body)) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "请求体须包含 userNote(字符串或 null)" }); |
||||
|
} |
||||
|
const note = body.userNote; |
||||
|
if (note !== null && typeof note !== "string") { |
||||
|
throw createError({ statusCode: 400, statusMessage: "userNote 须为字符串或 null" }); |
||||
|
} |
||||
|
const resolved = note === null ? null : note.trim() === "" ? null : note.trim(); |
||||
|
|
||||
|
await setMediaAssetUserNote({ |
||||
|
assetId, |
||||
|
note: resolved, |
||||
|
actorUserId: user.id, |
||||
|
actorIsAdmin: user.role === "admin", |
||||
|
}); |
||||
|
|
||||
|
return R.success({ ok: true }); |
||||
|
}); |
||||
@ -0,0 +1,226 @@ |
|||||
|
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<Map<number, MediaRefContextDto[]>> { |
||||
|
const map = new Map<number, MediaRefContextDto[]>(); |
||||
|
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<number, { ownerType: string; ownerId: number }[]>(); |
||||
|
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<number, { id: number; username: string; avatar: string | null; bioMarkdown: string | null }>(); |
||||
|
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)}…`; |
||||
|
} |
||||
Loading…
Reference in new issue