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