diff --git a/.env.example b/.env.example index 6309621..1bab83a 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DATABASE_URL=file:./db.sqlite NITRO_PORT=3399 # 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /public/assets/ 引用。生产环境务必设置,与浏览器访问地址一致。 -# NUXT_PUBLIC_SITE_URL=https://example.com +NUXT_PUBLIC_SITE_URL=https://example.com # Optional: first admin for an empty instance. Creates an admin only when no user has role=admin yet (same username/password rules as registration). BOOTSTRAP_ADMIN_USERNAME= BOOTSTRAP_ADMIN_PASSWORD= \ No newline at end of file diff --git a/app/pages/me/admin/media-storage.vue b/app/pages/me/admin/media-storage.vue index 5da2269..ff3cae3 100644 --- a/app/pages/me/admin/media-storage.vue +++ b/app/pages/me/admin/media-storage.vue @@ -5,12 +5,21 @@ definePageMeta({ title: '媒体存储校验' }) type RefDetail = { ownerType: string; ownerId: number } +type RefContext = { + ownerType: string + ownerId: number + label: string + href: string | null +} + type AuditRow = { id: number userId: number storageKey: string refCount: number refs: RefDetail[] + userNote: string | null + refContexts?: RefContext[] } type AuditReport = { @@ -48,6 +57,38 @@ const cleanupOpen = ref(false) const reuploadInput = ref(null) const reuploadTargetId = ref(null) const reuploadingId = ref(null) +const noteDraft = reactive>({}) +const savingNoteId = ref(null) + +watch( + () => report.value, + (rep) => { + if (!rep) { + return + } + for (const row of [...rep.missingOnDisk, ...rep.invalidStorageKey]) { + noteDraft[row.id] = row.userNote ?? '' + } + }, + { deep: true }, +) + +async function saveAdminNote(assetId: number) { + savingNoteId.value = assetId + try { + await fetchData(`/api/admin/media/assets/${assetId}/note`, { + method: 'PATCH', + body: { userNote: noteDraft[assetId] ?? '' }, + notify: false, + }) + toast.add({ title: '备注已保存', color: 'success' }) + await runAudit(false) + } catch (e: unknown) { + toast.add({ title: getApiErrorMessage(e), color: 'error' }) + } finally { + savingNoteId.value = null + } +} async function ensureAdmin() { await refresh(true) @@ -166,7 +207,7 @@ onMounted(async () => {
- + 运行校验 { 引用数 - - 引用来源(owner_type) + + 引用说明 + + + 文件描述 操作 @@ -232,8 +276,34 @@ onMounted(async () => { {{ row.refCount }} - - {{ formatRefSources(row.refs) }} + + + {{ formatRefSources(row.refs) }} + + + + + 保存备注 + { 引用数 - - 引用来源(owner_type) + + 引用说明 + + + 文件描述 操作 @@ -295,9 +368,35 @@ onMounted(async () => { {{ row.refCount }} - - - {{ formatRefSources(row.refs) }} + + + + {{ formatRefSources(row.refs) }} + + + + + 保存备注 + { 引用数 - - 引用来源(owner_type) + + 引用说明 + + + 文件描述 @@ -355,9 +457,35 @@ onMounted(async () => { {{ row.refCount }} - - - {{ formatRefSources(row.refs) }} + + + + {{ formatRefSources(row.refs) }} + + + + + 保存备注 + diff --git a/app/pages/me/media/index.vue b/app/pages/me/media/index.vue index 8389b29..d5a70e7 100644 --- a/app/pages/me/media/index.vue +++ b/app/pages/me/media/index.vue @@ -3,6 +3,13 @@ import { useAuthSession } from '../../../composables/useAuthSession' definePageMeta({ title: '媒体库' }) +type RefContext = { + ownerType: string + ownerId: number + label: string + href: string | null +} + type MediaAssetRow = { id: number storageKey: string @@ -11,6 +18,11 @@ type MediaAssetRow = { sizeBytes: number createdAt: string refCount: number + userNote: string | null + refContexts: RefContext[] + canDelete: boolean + deleteBlockedReason: 'referenced' | 'cooling' | null + deleteGraceExpiresAt: string | null } const toast = useToast() @@ -36,6 +48,12 @@ const pageSize = ref(20) const items = ref([]) const total = ref(0) const loading = ref(true) +/** 非首屏刷新(翻页、搜索):列表区轻量遮罩,避免旧数据与新结果切换生硬 */ +const listBusy = ref(false) +/** 输入框内容;`appliedSearch` 防抖后参与请求 */ +const searchInput = ref('') +const appliedSearch = ref('') +let searchDebounceTimer: ReturnType | null = null const uploading = ref(false) const fileInput = ref(null) const reuploadInput = ref(null) @@ -44,6 +62,30 @@ const reuploadingId = ref(null) /** 预览加载失败(多为磁盘缺文件) */ const previewFailedIds = ref>(new Set()) const imgBust = ref>({}) +const noteDraft = reactive>({}) +const savingNoteId = ref(null) +/** 正在编辑描述的资源 id;其余项用服务端文案展示 */ +const editingNoteId = ref(null) +/** 引用详情是否展开(按资源 id) */ +const refDetailOpen = reactive>({}) +const deleteConfirmOpen = ref(false) +const pendingDeleteAsset = ref(null) +const deletingId = ref(null) + +const skeletonPlaceholders = computed(() => Math.min(Math.max(pageSize.value, 1), 12)) + +watch( + items, + (list) => { + for (const it of list) { + if (editingNoteId.value === it.id) { + continue + } + noteDraft[it.id] = it.userNote ?? '' + } + }, + { immediate: true }, +) const pageSizeItems = [ { label: '每页 10', value: 10 }, @@ -69,13 +111,78 @@ function formatDt(iso: string | null): string { return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN') } +function deleteDisabledHint(item: MediaAssetRow): string { + if (item.canDelete) { + return '' + } + if (item.deleteBlockedReason === 'referenced') { + return '该文件仍被文章或资料引用,请先从正文、封面或资料中移除对应图片后再删除。' + } + if (item.deleteGraceExpiresAt) { + return `宽限期未满,预计 ${formatDt(item.deleteGraceExpiresAt)} 后可删除;也可在「媒体 → 孤儿清理」中查看进度。` + } + return '当前不可删除,请稍后在「孤儿清理」中处理。' +} + +function openDeleteConfirm(item: MediaAssetRow) { + if (!item.canDelete) { + return + } + pendingDeleteAsset.value = item + deleteConfirmOpen.value = true +} + +function closeDeleteModal() { + deleteConfirmOpen.value = false +} + +watch(deleteConfirmOpen, (open) => { + if (!open) { + pendingDeleteAsset.value = null + } +}) + +async function executeDelete() { + const item = pendingDeleteAsset.value + if (!item) { + return + } + deletingId.value = item.id + try { + await fetchData(`/api/me/media/assets/${item.id}`, { + method: 'DELETE', + notify: false, + }) + toast.add({ title: '已删除', color: 'success' }) + closeDeleteModal() + if (editingNoteId.value === item.id) { + editingNoteId.value = null + } + delete refDetailOpen[item.id] + await load() + } catch (e: unknown) { + toast.add({ title: getApiErrorMessage(e), color: 'error' }) + } finally { + deletingId.value = null + } +} + async function load() { - loading.value = true + const searchTrim = appliedSearch.value.trim() + const blocking = items.value.length === 0 && !searchTrim + if (blocking) { + loading.value = true + } else { + listBusy.value = true + } try { const q = new URLSearchParams({ page: String(page.value), pageSize: String(pageSize.value), }) + if (searchTrim) { + q.set('search', searchTrim) + } const body = await fetchData<{ items: MediaAssetRow[]; total: number }>( `/api/me/media/assets?${q.toString()}`, { notify: false }, @@ -87,7 +194,11 @@ async function load() { } catch (e: unknown) { toast.add({ title: getApiErrorMessage(e), color: 'error' }) } finally { - loading.value = false + if (blocking) { + loading.value = false + } else { + listBusy.value = false + } } } @@ -95,7 +206,24 @@ watch(pageSize, () => { page.value = 1 }) -watch([page, pageSize], () => { +watch(searchInput, () => { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer) + } + searchDebounceTimer = setTimeout(() => { + searchDebounceTimer = null + const next = searchInput.value.trim() + if (next === appliedSearch.value) { + return + } + if (page.value !== 1) { + page.value = 1 + } + appliedSearch.value = next + }, 350) +}) + +watch([page, pageSize, appliedSearch], () => { void load() }) @@ -104,6 +232,13 @@ onMounted(() => { void load() }) +onBeforeUnmount(() => { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer) + searchDebounceTimer = null + } +}) + function openFilePicker() { fileInput.value?.click() } @@ -166,6 +301,38 @@ function openReuploadPicker(assetId: number) { nextTick(() => reuploadInput.value?.click()) } +function startEditNote(item: MediaAssetRow) { + editingNoteId.value = item.id + noteDraft[item.id] = item.userNote ?? '' +} + +function cancelEditNote(item: MediaAssetRow) { + editingNoteId.value = null + noteDraft[item.id] = item.userNote ?? '' +} + +async function saveMyNote(assetId: number) { + savingNoteId.value = assetId + try { + await fetchData(`/api/me/media/assets/${assetId}/note`, { + method: 'PATCH', + body: { userNote: noteDraft[assetId] ?? '' }, + notify: false, + }) + toast.add({ title: '描述已保存', color: 'success' }) + editingNoteId.value = null + await load() + } catch (e: unknown) { + toast.add({ title: getApiErrorMessage(e), color: 'error' }) + } finally { + savingNoteId.value = null + } +} + +function toggleRefDetail(id: number) { + refDetailOpen[id] = !refDetailOpen[id] +} + async function onReuploadFile(ev: Event) { const assetId = reuploadTargetId.value reuploadTargetId.value = null @@ -245,20 +412,57 @@ async function copyMarkdown(item: MediaAssetRow) { > 选择图片上传 + + +
-
- 加载中… -
- -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
@@ -300,20 +504,131 @@ async function copyMarkdown(item: MediaAssetRow) {

引用数: {{ item.refCount }}

-
+
+ + {{ refDetailOpen[item.id] ? '收起引用详情' : `查看引用详情(${item.refContexts.length})` }} + +
+
+ + {{ c.label }} + + {{ c.label }} +
+
+
+
+
+ 文件描述 +
+ + +
+
复制 URL 复制 Markdown + + + 删除 + +
+

+ {{ deleteDisabledHint(item) }} +

+
-
- +
+
+ + + + +
diff --git a/packages/drizzle-pkg/database/sqlite/schema/content.ts b/packages/drizzle-pkg/database/sqlite/schema/content.ts index 2c825e6..6066aba 100644 --- a/packages/drizzle-pkg/database/sqlite/schema/content.ts +++ b/packages/drizzle-pkg/database/sqlite/schema/content.ts @@ -50,6 +50,8 @@ export const mediaAssets = sqliteTable( status: text().notNull().default("ready"), firstReferencedAt: integer("first_referenced_at", { mode: "timestamp_ms" }), dereferencedAt: integer("dereferenced_at", { mode: "timestamp_ms" }), + /** 人工备注:缺失补传提示、图片来源说明等 */ + userNote: text("user_note"), createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), }, (table) => [ diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 5f7e7c0..6f45afc 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/packages/drizzle-pkg/migrations/0008_media_assets_user_note.sql b/packages/drizzle-pkg/migrations/0008_media_assets_user_note.sql new file mode 100644 index 0000000..ed3caee --- /dev/null +++ b/packages/drizzle-pkg/migrations/0008_media_assets_user_note.sql @@ -0,0 +1 @@ +ALTER TABLE `media_assets` ADD `user_note` text; diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json index 38caf66..a33a774 100644 --- a/packages/drizzle-pkg/migrations/meta/_journal.json +++ b/packages/drizzle-pkg/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1776900000000, "tag": "0007_vengeful_puck", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1777000000000, + "tag": "0008_media_assets_user_note", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/admin/media/assets/[id]/note.patch.ts b/server/api/admin/media/assets/[id]/note.patch.ts new file mode 100644 index 0000000..a8e7aef --- /dev/null +++ b/server/api/admin/media/assets/[id]/note.patch.ts @@ -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 }); +}); diff --git a/server/api/admin/media/storage-audit.get.ts b/server/api/admin/media/storage-audit.get.ts index 47dfd03..8c66eb4 100644 --- a/server/api/admin/media/storage-audit.get.ts +++ b/server/api/admin/media/storage-audit.get.ts @@ -4,10 +4,10 @@ import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; import { getRequestIP } from "h3"; export default defineWrappedResponseHandler(async (event) => { - await requireAdmin(event); + const admin = await requireAdmin(event); const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; assertUnderRateLimit(`admin-media-storage-audit:${ip}`, 30, 60_000); - const report = await auditMediaStorageVsDb(); + const report = await auditMediaStorageVsDb({ userId: admin.id, role: admin.role }); return R.success(report); }); diff --git a/server/api/me/media/assets.get.ts b/server/api/me/media/assets.get.ts index d48ab44..db203af 100644 --- a/server/api/me/media/assets.get.ts +++ b/server/api/me/media/assets.get.ts @@ -1,24 +1,35 @@ +import { POST_MEDIA_PUBLIC_PREFIX } from "#server/constants/media"; import { listUserMediaAssetsPage } from "#server/service/media"; import { parseMeMediaAssetsQuery } from "#server/utils/me-media-assets-query"; export default defineWrappedResponseHandler(async (event) => { const user = await event.context.auth.requireUser(); const q = getQuery(event); - const { page, pageSize } = parseMeMediaAssetsQuery({ + const searchRaw = Array.isArray(q.search) ? q.search[0] : q.search; + const { page, pageSize, search } = parseMeMediaAssetsQuery({ page: typeof q.page === "string" ? q.page : undefined, pageSize: typeof q.pageSize === "string" ? q.pageSize : undefined, + search: typeof searchRaw === "string" ? searchRaw : undefined, }); - const { items, total } = await listUserMediaAssetsPage(user.id, page, pageSize); + const { items, total } = await listUserMediaAssetsPage(user.id, page, pageSize, { + userId: user.id, + role: user.role, + }, { search }); return R.success({ items: items.map((r) => ({ id: r.id, storageKey: r.storageKey, - publicPath: "/public/assets/" + r.storageKey, + publicPath: `${POST_MEDIA_PUBLIC_PREFIX}${r.storageKey}`, mime: r.mime, sizeBytes: r.sizeBytes, createdAt: r.createdAt.toISOString(), refCount: r.refCount, + userNote: r.userNote, + refContexts: r.refContexts, + canDelete: r.canDelete, + deleteBlockedReason: r.deleteBlockedReason, + deleteGraceExpiresAt: r.deleteGraceExpiresAt, })), total, }); diff --git a/server/api/me/media/assets/[id].delete.ts b/server/api/me/media/assets/[id].delete.ts new file mode 100644 index 0000000..7ec71e1 --- /dev/null +++ b/server/api/me/media/assets/[id].delete.ts @@ -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 }); +}); diff --git a/server/api/me/media/assets/[id]/note.patch.ts b/server/api/me/media/assets/[id]/note.patch.ts new file mode 100644 index 0000000..2f78688 --- /dev/null +++ b/server/api/me/media/assets/[id]/note.patch.ts @@ -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 }); +}); diff --git a/server/service/media/index.ts b/server/service/media/index.ts index dc9a9d3..799be6b 100644 --- a/server/service/media/index.ts +++ b/server/service/media/index.ts @@ -2,8 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import sharp from "sharp"; 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 { users } from "drizzle-pkg/lib/schema/auth"; +import { mediaAssets, mediaRefs, posts } from "drizzle-pkg/lib/schema/content"; +import { alias } from "drizzle-orm/sqlite-core"; +import { and, count, desc, eq, exists, inArray, isNotNull, isNull, lte, not, notExists, or, sql } from "drizzle-orm"; import { MEDIA_IMAGE_MAX_WIDTH_PX, MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF, @@ -13,6 +15,7 @@ import { RELATIVE_ASSETS_DIR, } from "#server/constants/media"; import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs"; +import { buildRefContextsForAssets, type MediaRefContextDto } from "#server/service/media/ref-context"; import { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls"; import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public"; import { nextIntegerId } from "#server/utils/sqlite-id"; @@ -470,6 +473,46 @@ export async function purgeAllDeletableOrphansGlobally(limit: number): Promise { - const [{ total: totalRaw }] = await dbGlobal - .select({ total: count() }) - .from(mediaAssets) - .where(eq(mediaAssets.userId, userId)); + const term = typeof opts?.search === "string" ? opts.search.trim() : ""; + const userFilter = eq(mediaAssets.userId, userId); + const whereClause = term ? and(userFilter, userMediaAssetsSearchCondition(term)) : userFilter; + + const [{ total: totalRaw }] = await dbGlobal.select({ total: count() }).from(mediaAssets).where(whereClause); const total = typeof totalRaw === "bigint" ? Number(totalRaw) : Number(totalRaw); @@ -499,9 +553,12 @@ export async function listUserMediaAssetsPage( mime: mediaAssets.mime, sizeBytes: mediaAssets.sizeBytes, createdAt: mediaAssets.createdAt, + userNote: mediaAssets.userNote, + firstReferencedAt: mediaAssets.firstReferencedAt, + dereferencedAt: mediaAssets.dereferencedAt, }) .from(mediaAssets) - .where(eq(mediaAssets.userId, userId)) + .where(whereClause) .orderBy(desc(mediaAssets.createdAt)) .limit(pageSize) .offset(offset); @@ -526,14 +583,65 @@ export async function listUserMediaAssetsPage( refMap.set(r.assetId, typeof c === "bigint" ? Number(c) : Number(c)); } - const items: UserMediaAssetListRow[] = rows.map((row) => ({ - id: row.id, - storageKey: row.storageKey, - mime: row.mime, - sizeBytes: row.sizeBytes, - createdAt: row.createdAt, - refCount: refMap.get(row.id) ?? 0, - })); + const ctxMap = await buildRefContextsForAssets( + rows.map((row) => ({ id: row.id, storageKey: row.storageKey })), + viewer, + ); + + const items: UserMediaAssetListRow[] = rows.map((row) => { + const refCount = refMap.get(row.id) ?? 0; + const graceRow = { + firstReferencedAt: row.firstReferencedAt, + dereferencedAt: row.dereferencedAt, + createdAt: row.createdAt, + }; + const deletable = isAssetDeletable(graceRow); + const canDelete = refCount === 0 && deletable; + let deleteBlockedReason: "referenced" | "cooling" | null = null; + if (refCount > 0) { + deleteBlockedReason = "referenced"; + } else if (!deletable) { + deleteBlockedReason = "cooling"; + } + const deleteGraceExpiresAt = + deleteBlockedReason === "cooling" ? (computeOrphanGraceExpiresAt(graceRow)?.toISOString() ?? null) : null; + return { + id: row.id, + storageKey: row.storageKey, + mime: row.mime, + sizeBytes: row.sizeBytes, + createdAt: row.createdAt, + refCount, + userNote: row.userNote ?? null, + refContexts: ctxMap.get(row.id) ?? [], + canDelete, + deleteBlockedReason, + deleteGraceExpiresAt, + }; + }); return { items, total }; } + +export async function setMediaAssetUserNote(params: { + assetId: number; + note: string | null; + actorUserId: number; + actorIsAdmin: boolean; +}): Promise { + const [row] = await dbGlobal.select().from(mediaAssets).where(eq(mediaAssets.id, params.assetId)).limit(1); + if (!row) { + throw createError({ statusCode: 404, statusMessage: "媒体不存在" }); + } + if (!params.actorIsAdmin && row.userId !== params.actorUserId) { + throw createError({ statusCode: 403, statusMessage: "无权操作该媒体" }); + } + const trimmed = params.note === null ? null : params.note.trim(); + if (trimmed && trimmed.length > 500) { + throw createError({ statusCode: 400, statusMessage: "备注最多 500 字" }); + } + await dbGlobal + .update(mediaAssets) + .set({ userNote: trimmed && trimmed.length > 0 ? trimmed : null }) + .where(eq(mediaAssets.id, params.assetId)); +} diff --git a/server/service/media/ref-context.ts b/server/service/media/ref-context.ts new file mode 100644 index 0000000..ed8532e --- /dev/null +++ b/server/service/media/ref-context.ts @@ -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> { + 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)}…`; +} diff --git a/server/service/media/storage-audit.ts b/server/service/media/storage-audit.ts index 213b00f..dd18d1a 100644 --- a/server/service/media/storage-audit.ts +++ b/server/service/media/storage-audit.ts @@ -4,6 +4,7 @@ import { dbGlobal } from "drizzle-pkg/lib/db"; import { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content"; import { eq } from "drizzle-orm"; import { RELATIVE_ASSETS_DIR } from "#server/constants/media"; +import { buildRefContextsForAssets, type MediaRefContextDto } from "#server/service/media/ref-context"; function assetsBaseDir(): string { return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR); @@ -40,6 +41,9 @@ export type StorageAuditMissingRow = { storageKey: string; refCount: number; refs: StorageAuditRefDetail[]; + userNote: string | null; + /** 传入 `viewer` 时由审计填充 */ + refContexts?: MediaRefContextDto[]; }; export type StorageAuditResult = { @@ -51,13 +55,14 @@ export type StorageAuditResult = { onDiskNotInDb: string[]; }; -export async function auditMediaStorageVsDb(): Promise { +export async function auditMediaStorageVsDb(viewer?: { userId: number; role: string }): Promise { const base = assetsBaseDir(); const rows = await dbGlobal .select({ id: mediaAssets.id, userId: mediaAssets.userId, storageKey: mediaAssets.storageKey, + userNote: mediaAssets.userNote, }) .from(mediaAssets); @@ -103,6 +108,7 @@ export async function auditMediaStorageVsDb(): Promise { storageKey: r.storageKey, refCount, refs, + userNote: r.userNote ?? null, }); continue; } @@ -115,6 +121,7 @@ export async function auditMediaStorageVsDb(): Promise { storageKey: r.storageKey, refCount, refs, + userNote: r.userNote ?? null, }); continue; } @@ -125,6 +132,7 @@ export async function auditMediaStorageVsDb(): Promise { storageKey: r.storageKey, refCount, refs, + userNote: r.userNote ?? null, }); } } @@ -157,6 +165,11 @@ export async function auditMediaStorageVsDb(): Promise { } onDiskNotInDb.sort(); + if (viewer) { + await attachRefContexts(missingOnDisk, viewer); + await attachRefContexts(invalidStorageKey, viewer); + } + return { scannedAt: new Date().toISOString(), dbRowCount: rows.length, @@ -167,6 +180,22 @@ export async function auditMediaStorageVsDb(): Promise { }; } +async function attachRefContexts( + list: StorageAuditMissingRow[], + viewer: { userId: number; role: string }, +): Promise { + if (list.length === 0) { + return; + } + const map = await buildRefContextsForAssets( + list.map((m) => ({ id: m.id, storageKey: m.storageKey })), + viewer, + ); + for (const row of list) { + row.refContexts = map.get(row.id) ?? []; + } +} + /** * 删除「磁盘上不存在」且「无任何文章引用」的 media_assets 行(不尝试 unlink)。 */ diff --git a/server/utils/me-media-assets-query.test.ts b/server/utils/me-media-assets-query.test.ts index 8515e3c..ee91025 100644 --- a/server/utils/me-media-assets-query.test.ts +++ b/server/utils/me-media-assets-query.test.ts @@ -2,13 +2,31 @@ import { describe, expect, test } from "bun:test"; import { parseMeMediaAssetsQuery } from "./me-media-assets-query"; describe("parseMeMediaAssetsQuery", () => { - test("defaults: {} -> { page: 1, pageSize: 20 }", () => { - expect(parseMeMediaAssetsQuery({})).toEqual({ page: 1, pageSize: 20 }); + test("defaults: {} -> { page: 1, pageSize: 20, search: null }", () => { + expect(parseMeMediaAssetsQuery({})).toEqual({ page: 1, pageSize: 20, search: null }); }); test("pageSize 10 and 50 are accepted", () => { - expect(parseMeMediaAssetsQuery({ pageSize: "10" })).toEqual({ page: 1, pageSize: 10 }); - expect(parseMeMediaAssetsQuery({ pageSize: "50" })).toEqual({ page: 1, pageSize: 50 }); + expect(parseMeMediaAssetsQuery({ pageSize: "10" })).toEqual({ page: 1, pageSize: 10, search: null }); + expect(parseMeMediaAssetsQuery({ pageSize: "50" })).toEqual({ page: 1, pageSize: 50, search: null }); + }); + + test("search trims and empty becomes null", () => { + expect(parseMeMediaAssetsQuery({ search: " hello " })).toEqual({ + page: 1, + pageSize: 20, + search: "hello", + }); + expect(parseMeMediaAssetsQuery({ search: " " })).toEqual({ page: 1, pageSize: 20, search: null }); + }); + + test("search over 200 chars throws 400", () => { + try { + parseMeMediaAssetsQuery({ search: "x".repeat(201) }); + expect.unreachable(); + } catch (e: unknown) { + expect(e).toMatchObject({ statusCode: 400 }); + } }); test("pageSize 15 throws with statusCode 400", () => { diff --git a/server/utils/me-media-assets-query.ts b/server/utils/me-media-assets-query.ts index 16b166d..145adb9 100644 --- a/server/utils/me-media-assets-query.ts +++ b/server/utils/me-media-assets-query.ts @@ -12,15 +12,25 @@ function parsePositiveInt(raw: string | undefined, fallback: number, label: stri } const ALLOWED_PAGE_SIZES = new Set([10, 20, 50]); +const MAX_SEARCH_LEN = 200; -export function parseMeMediaAssetsQuery(q: { page?: string; pageSize?: string }): { +export function parseMeMediaAssetsQuery(q: { page?: string; pageSize?: string; search?: string }): { page: number; pageSize: number; + search: string | null; } { const page = parsePositiveInt(q.page, 1, "page"); const pageSizeRaw = parsePositiveInt(q.pageSize, 20, "pageSize"); if (!ALLOWED_PAGE_SIZES.has(pageSizeRaw)) { throw createError({ statusCode: 400, statusMessage: "pageSize 须为 10、20 或 50" }); } - return { page, pageSize: pageSizeRaw }; + let search: string | null = null; + if (typeof q.search === "string") { + const s = q.search.trim(); + if (s.length > MAX_SEARCH_LEN) { + throw createError({ statusCode: 400, statusMessage: `search 最多 ${MAX_SEARCH_LEN} 个字符` }); + } + search = s.length ? s : null; + } + return { page, pageSize: pageSizeRaw, search }; }