Browse Source

feat(media): add user notes and reference contexts to media assets

- 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: Cursor
tags/邮箱功能前置
npmrun 2 months ago
parent
commit
78aa56f52a
  1. 2
      .env.example
  2. 158
      app/pages/me/admin/media-storage.vue
  3. 345
      app/pages/me/media/index.vue
  4. 2
      packages/drizzle-pkg/database/sqlite/schema/content.ts
  5. BIN
      packages/drizzle-pkg/db.sqlite
  6. 1
      packages/drizzle-pkg/migrations/0008_media_assets_user_note.sql
  7. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  8. 35
      server/api/admin/media/assets/[id]/note.patch.ts
  9. 4
      server/api/admin/media/storage-audit.get.ts
  10. 17
      server/api/me/media/assets.get.ts
  11. 14
      server/api/me/media/assets/[id].delete.ts
  12. 29
      server/api/me/media/assets/[id]/note.patch.ts
  13. 138
      server/service/media/index.ts
  14. 226
      server/service/media/ref-context.ts
  15. 31
      server/service/media/storage-audit.ts
  16. 26
      server/utils/me-media-assets-query.test.ts
  17. 14
      server/utils/me-media-assets-query.ts

2
.env.example

@ -2,7 +2,7 @@
DATABASE_URL=file:./db.sqlite DATABASE_URL=file:./db.sqlite
NITRO_PORT=3399 NITRO_PORT=3399
# 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /public/assets/ 引用。生产环境务必设置,与浏览器访问地址一致。 # 站点对外根 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). # 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_USERNAME=
BOOTSTRAP_ADMIN_PASSWORD= BOOTSTRAP_ADMIN_PASSWORD=

158
app/pages/me/admin/media-storage.vue

@ -5,12 +5,21 @@ definePageMeta({ title: '媒体存储校验' })
type RefDetail = { ownerType: string; ownerId: number } type RefDetail = { ownerType: string; ownerId: number }
type RefContext = {
ownerType: string
ownerId: number
label: string
href: string | null
}
type AuditRow = { type AuditRow = {
id: number id: number
userId: number userId: number
storageKey: string storageKey: string
refCount: number refCount: number
refs: RefDetail[] refs: RefDetail[]
userNote: string | null
refContexts?: RefContext[]
} }
type AuditReport = { type AuditReport = {
@ -48,6 +57,38 @@ const cleanupOpen = ref(false)
const reuploadInput = ref<HTMLInputElement | null>(null) const reuploadInput = ref<HTMLInputElement | null>(null)
const reuploadTargetId = ref<number | null>(null) const reuploadTargetId = ref<number | null>(null)
const reuploadingId = ref<number | null>(null) const reuploadingId = ref<number | null>(null)
const noteDraft = reactive<Record<number, string>>({})
const savingNoteId = ref<number | null>(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() { async function ensureAdmin() {
await refresh(true) await refresh(true)
@ -166,7 +207,7 @@ onMounted(async () => {
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<UButton :loading="loading" @click="runAudit"> <UButton :loading="loading" @click="() => runAudit()">
运行校验 运行校验
</UButton> </UButton>
<UButton <UButton
@ -210,8 +251,11 @@ onMounted(async () => {
<th class="p-2"> <th class="p-2">
引用数 引用数
</th> </th>
<th class="p-2 min-w-[12rem]"> <th class="p-2 min-w-[14rem]">
引用来源owner_type 引用说明
</th>
<th class="p-2 min-w-[10rem] max-w-[12rem]">
文件描述
</th> </th>
<th class="p-2 w-28 whitespace-nowrap"> <th class="p-2 w-28 whitespace-nowrap">
操作 操作
@ -232,8 +276,34 @@ onMounted(async () => {
<td class="p-2 tabular-nums"> <td class="p-2 tabular-nums">
{{ row.refCount }} {{ row.refCount }}
</td> </td>
<td class="p-2 text-xs align-top"> <td class="p-2 text-xs align-top space-y-1 min-w-[14rem]">
{{ formatRefSources(row.refs) }} <template v-if="(row.refContexts ?? []).length">
<div v-for="(c, idx) in row.refContexts" :key="idx">
<NuxtLink
v-if="c.href"
:to="c.href"
class="text-primary underline-offset-2 hover:underline"
:target="c.href.startsWith('/@') ? '_blank' : undefined"
>
{{ c.label }}
</NuxtLink>
<span v-else class="text-default leading-snug">{{ c.label }}</span>
</div>
</template>
<span v-else class="text-muted">{{ formatRefSources(row.refs) }}</span>
</td>
<td class="p-2 align-top min-w-[10rem] max-w-xs">
<UTextarea v-model="noteDraft[row.id]" :rows="2" class="text-xs w-full" />
<UButton
size="xs"
class="mt-1"
variant="soft"
:loading="savingNoteId === row.id"
:disabled="loading"
@click="saveAdminNote(row.id)"
>
保存备注
</UButton>
</td> </td>
<td class="p-2 align-top"> <td class="p-2 align-top">
<UButton <UButton
@ -273,8 +343,11 @@ onMounted(async () => {
<th class="p-2"> <th class="p-2">
引用数 引用数
</th> </th>
<th class="p-2 min-w-[12rem]"> <th class="p-2 min-w-[14rem]">
引用来源owner_type 引用说明
</th>
<th class="p-2 min-w-[10rem] max-w-[12rem]">
文件描述
</th> </th>
<th class="p-2 w-28 whitespace-nowrap"> <th class="p-2 w-28 whitespace-nowrap">
操作 操作
@ -295,9 +368,35 @@ onMounted(async () => {
<td class="p-2 tabular-nums"> <td class="p-2 tabular-nums">
{{ row.refCount }} {{ row.refCount }}
</td> </td>
<td class="p-2 text-xs align-top"> <td class="p-2 text-xs align-top space-y-1 min-w-[14rem]">
<span v-if="row.refCount === 0" class="text-muted"></span> <template v-if="(row.refContexts ?? []).length">
<span v-else>{{ formatRefSources(row.refs) }}</span> <div v-for="(c, idx) in row.refContexts" :key="idx">
<NuxtLink
v-if="c.href"
:to="c.href"
class="text-primary underline-offset-2 hover:underline"
:target="c.href.startsWith('/@') ? '_blank' : undefined"
>
{{ c.label }}
</NuxtLink>
<span v-else class="text-default leading-snug">{{ c.label }}</span>
</div>
</template>
<span v-else-if="row.refCount === 0" class="text-muted"></span>
<span v-else class="text-muted">{{ formatRefSources(row.refs) }}</span>
</td>
<td class="p-2 align-top min-w-[10rem] max-w-xs">
<UTextarea v-model="noteDraft[row.id]" :rows="2" class="text-xs w-full" />
<UButton
size="xs"
class="mt-1"
variant="soft"
:loading="savingNoteId === row.id"
:disabled="loading"
@click="saveAdminNote(row.id)"
>
保存备注
</UButton>
</td> </td>
<td class="p-2 align-top"> <td class="p-2 align-top">
<UButton <UButton
@ -336,8 +435,11 @@ onMounted(async () => {
<th class="p-2"> <th class="p-2">
引用数 引用数
</th> </th>
<th class="p-2 min-w-[12rem]"> <th class="p-2 min-w-[14rem]">
引用来源owner_type 引用说明
</th>
<th class="p-2 min-w-[10rem] max-w-[12rem]">
文件描述
</th> </th>
</tr> </tr>
</thead> </thead>
@ -355,9 +457,35 @@ onMounted(async () => {
<td class="p-2 tabular-nums"> <td class="p-2 tabular-nums">
{{ row.refCount }} {{ row.refCount }}
</td> </td>
<td class="p-2 text-xs align-top"> <td class="p-2 text-xs align-top space-y-1 min-w-[14rem]">
<span v-if="row.refCount === 0" class="text-muted"></span> <template v-if="(row.refContexts ?? []).length">
<span v-else>{{ formatRefSources(row.refs) }}</span> <div v-for="(c, idx) in row.refContexts" :key="idx">
<NuxtLink
v-if="c.href"
:to="c.href"
class="text-primary underline-offset-2 hover:underline"
:target="c.href.startsWith('/@') ? '_blank' : undefined"
>
{{ c.label }}
</NuxtLink>
<span v-else class="text-default leading-snug">{{ c.label }}</span>
</div>
</template>
<span v-else-if="row.refCount === 0" class="text-muted"></span>
<span v-else class="text-muted">{{ formatRefSources(row.refs) }}</span>
</td>
<td class="p-2 align-top min-w-[10rem] max-w-xs">
<UTextarea v-model="noteDraft[row.id]" :rows="2" class="text-xs w-full" />
<UButton
size="xs"
class="mt-1"
variant="soft"
:loading="savingNoteId === row.id"
:disabled="loading"
@click="saveAdminNote(row.id)"
>
保存备注
</UButton>
</td> </td>
</tr> </tr>
</tbody> </tbody>

345
app/pages/me/media/index.vue

@ -3,6 +3,13 @@ import { useAuthSession } from '../../../composables/useAuthSession'
definePageMeta({ title: '媒体库' }) definePageMeta({ title: '媒体库' })
type RefContext = {
ownerType: string
ownerId: number
label: string
href: string | null
}
type MediaAssetRow = { type MediaAssetRow = {
id: number id: number
storageKey: string storageKey: string
@ -11,6 +18,11 @@ type MediaAssetRow = {
sizeBytes: number sizeBytes: number
createdAt: string createdAt: string
refCount: number refCount: number
userNote: string | null
refContexts: RefContext[]
canDelete: boolean
deleteBlockedReason: 'referenced' | 'cooling' | null
deleteGraceExpiresAt: string | null
} }
const toast = useToast() const toast = useToast()
@ -36,6 +48,12 @@ const pageSize = ref(20)
const items = ref<MediaAssetRow[]>([]) const items = ref<MediaAssetRow[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(true) const loading = ref(true)
/** 非首屏刷新(翻页、搜索):列表区轻量遮罩,避免旧数据与新结果切换生硬 */
const listBusy = ref(false)
/** 输入框内容;`appliedSearch` 防抖后参与请求 */
const searchInput = ref('')
const appliedSearch = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const uploading = ref(false) const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const reuploadInput = ref<HTMLInputElement | null>(null) const reuploadInput = ref<HTMLInputElement | null>(null)
@ -44,6 +62,30 @@ const reuploadingId = ref<number | null>(null)
/** 预览加载失败(多为磁盘缺文件) */ /** 预览加载失败(多为磁盘缺文件) */
const previewFailedIds = ref<Set<number>>(new Set()) const previewFailedIds = ref<Set<number>>(new Set())
const imgBust = ref<Record<number, number>>({}) const imgBust = ref<Record<number, number>>({})
const noteDraft = reactive<Record<number, string>>({})
const savingNoteId = ref<number | null>(null)
/** 正在编辑描述的资源 id;其余项用服务端文案展示 */
const editingNoteId = ref<number | null>(null)
/** 引用详情是否展开(按资源 id) */
const refDetailOpen = reactive<Record<number, boolean>>({})
const deleteConfirmOpen = ref(false)
const pendingDeleteAsset = ref<MediaAssetRow | null>(null)
const deletingId = ref<number | null>(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 = [ const pageSizeItems = [
{ label: '每页 10', value: 10 }, { label: '每页 10', value: 10 },
@ -69,13 +111,78 @@ function formatDt(iso: string | null): string {
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN') 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() { 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 { try {
const q = new URLSearchParams({ const q = new URLSearchParams({
page: String(page.value), page: String(page.value),
pageSize: String(pageSize.value), pageSize: String(pageSize.value),
}) })
if (searchTrim) {
q.set('search', searchTrim)
}
const body = await fetchData<{ items: MediaAssetRow[]; total: number }>( const body = await fetchData<{ items: MediaAssetRow[]; total: number }>(
`/api/me/media/assets?${q.toString()}`, `/api/me/media/assets?${q.toString()}`,
{ notify: false }, { notify: false },
@ -87,7 +194,11 @@ async function load() {
} catch (e: unknown) { } catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' }) toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally { } finally {
loading.value = false if (blocking) {
loading.value = false
} else {
listBusy.value = false
}
} }
} }
@ -95,7 +206,24 @@ watch(pageSize, () => {
page.value = 1 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() void load()
}) })
@ -104,6 +232,13 @@ onMounted(() => {
void load() void load()
}) })
onBeforeUnmount(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
})
function openFilePicker() { function openFilePicker() {
fileInput.value?.click() fileInput.value?.click()
} }
@ -166,6 +301,38 @@ function openReuploadPicker(assetId: number) {
nextTick(() => reuploadInput.value?.click()) 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) { async function onReuploadFile(ev: Event) {
const assetId = reuploadTargetId.value const assetId = reuploadTargetId.value
reuploadTargetId.value = null reuploadTargetId.value = null
@ -245,20 +412,57 @@ async function copyMarkdown(item: MediaAssetRow) {
> >
选择图片上传 选择图片上传
</UButton> </UButton>
<UFormField label="搜索" class="min-w-[12rem] flex-1 max-w-xl">
<UInput
v-model="searchInput"
placeholder="文件名、引用(文章标题 / 用户名等)、文件描述"
icon="i-lucide-search"
class="w-full"
:disabled="uploading"
/>
</UFormField>
<UFormField label="每页" class="w-40 shrink-0"> <UFormField label="每页" class="w-40 shrink-0">
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" /> <USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
</UFormField> </UFormField>
</div> </div>
<div v-if="loading" class="text-muted"> <div class="min-h-[22rem] sm:min-h-[26rem]">
加载中 <div
</div> v-if="loading"
<UEmpty class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
v-else-if="!items.length" aria-busy="true"
title="暂无图片" aria-label="加载中"
description="上传 PNG / JPEG / WebP 后,将在此列出并可直接复制链接。" >
/> <div
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> v-for="n in skeletonPlaceholders"
:key="n"
class="rounded-lg border border-default bg-default p-4 space-y-3 shadow-sm"
>
<div class="relative w-full aspect-video rounded-md bg-elevated/80 animate-pulse" />
<div class="h-3 w-[85%] max-w-[14rem] rounded bg-elevated/80 animate-pulse" />
<div class="flex gap-2">
<div class="h-3 w-16 rounded bg-elevated/60 animate-pulse" />
<div class="h-3 w-24 rounded bg-elevated/60 animate-pulse" />
</div>
<div class="h-3 w-20 rounded bg-elevated/50 animate-pulse" />
<div class="h-8 w-full rounded bg-elevated/40 animate-pulse" />
</div>
</div>
<UEmpty
v-else-if="!items.length"
:title="appliedSearch ? '无匹配项' : '暂无图片'"
:description="
appliedSearch
? '没有匹配当前关键词的文件名、引用或描述,可更换关键词或清空搜索。'
: '上传 PNG / JPEG / WebP 后,将在此列出并可直接复制链接。'
"
:class="{ 'opacity-60 pointer-events-none': listBusy }"
/>
<div
v-else
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
:class="{ 'opacity-60 pointer-events-none': listBusy }"
>
<UCard v-for="item in items" :key="item.id"> <UCard v-for="item in items" :key="item.id">
<div class="space-y-3"> <div class="space-y-3">
<div class="relative w-full aspect-video rounded-md border border-default overflow-hidden bg-elevated/30"> <div class="relative w-full aspect-video rounded-md border border-default overflow-hidden bg-elevated/30">
@ -300,20 +504,131 @@ async function copyMarkdown(item: MediaAssetRow) {
<p class="text-xs text-default"> <p class="text-xs text-default">
引用数: {{ item.refCount }} 引用数: {{ item.refCount }}
</p> </p>
<div class="flex flex-wrap gap-2"> <div v-if="item.refContexts.length" class="text-xs space-y-1">
<UButton
size="xs"
variant="link"
color="neutral"
class="p-0 h-auto font-normal"
@click="toggleRefDetail(item.id)"
>
{{ refDetailOpen[item.id] ? '收起引用详情' : `查看引用详情(${item.refContexts.length}` }}
</UButton>
<div v-show="refDetailOpen[item.id]" class="text-muted space-y-1 border-l border-default pl-2 ml-0.5">
<div v-for="(c, idx) in item.refContexts" :key="idx">
<NuxtLink
v-if="c.href"
:to="c.href"
class="text-primary underline-offset-2 hover:underline"
>
{{ c.label }}
</NuxtLink>
<span v-else>{{ c.label }}</span>
</div>
</div>
</div>
<div class="text-xs space-y-1">
<div class="text-muted">
文件描述
</div>
<template v-if="editingNoteId === item.id">
<UTextarea v-model="noteDraft[item.id]" :rows="2" class="text-xs w-full" />
<div class="flex flex-wrap gap-2">
<UButton
size="xs"
color="primary"
:loading="savingNoteId === item.id"
:disabled="loading || listBusy"
@click="saveMyNote(item.id)"
>
保存
</UButton>
<UButton
size="xs"
variant="ghost"
:disabled="savingNoteId === item.id"
@click="cancelEditNote(item)"
>
取消
</UButton>
</div>
</template>
<template v-else>
<p class="text-default whitespace-pre-wrap break-words min-h-[1.25rem]">
{{ (item.userNote ?? '').trim() ? item.userNote : '—' }}
</p>
<UButton size="xs" variant="soft" @click="startEditNote(item)">
修改描述
</UButton>
</template>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton size="xs" variant="soft" @click="copyUrl(item)"> <UButton size="xs" variant="soft" @click="copyUrl(item)">
复制 URL 复制 URL
</UButton> </UButton>
<UButton size="xs" variant="soft" @click="copyMarkdown(item)"> <UButton size="xs" variant="soft" @click="copyMarkdown(item)">
复制 Markdown 复制 Markdown
</UButton> </UButton>
<span class="inline-flex">
<UButton
size="xs"
color="error"
variant="soft"
:disabled="!item.canDelete || listBusy || loading || deletingId !== null"
@click="openDeleteConfirm(item)"
>
删除
</UButton>
</span>
</div> </div>
<p
v-if="!item.canDelete && item.deleteBlockedReason"
class="text-[11px] text-muted leading-snug"
>
{{ deleteDisabledHint(item) }}
</p>
</div> </div>
</UCard> </UCard>
</div>
</div> </div>
<div v-if="!loading && total > pageSize" class="flex justify-end"> <div class="min-h-11 flex justify-end items-center">
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" size="sm" /> <UPagination
v-if="!loading && !listBusy && total > pageSize"
v-model:page="page"
:total="total"
:items-per-page="pageSize"
size="sm"
/>
</div> </div>
<UModal v-model:open="deleteConfirmOpen" title="确认删除图片">
<template #body>
<p class="text-sm text-default">
将永久删除文件并移除库记录不可恢复
</p>
<p
v-if="pendingDeleteAsset"
class="mt-2 font-mono text-xs text-muted break-all"
>
{{ pendingDeleteAsset.storageKey }}
</p>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
variant="outline"
color="neutral"
:disabled="deletingId !== null"
@click="closeDeleteModal"
>
取消
</UButton>
<UButton color="error" :loading="deletingId !== null" @click="executeDelete">
删除
</UButton>
</div>
</template>
</UModal>
</div> </div>
</template> </template>

2
packages/drizzle-pkg/database/sqlite/schema/content.ts

@ -50,6 +50,8 @@ export const mediaAssets = sqliteTable(
status: text().notNull().default("ready"), status: text().notNull().default("ready"),
firstReferencedAt: integer("first_referenced_at", { mode: "timestamp_ms" }), firstReferencedAt: integer("first_referenced_at", { mode: "timestamp_ms" }),
dereferencedAt: integer("dereferenced_at", { mode: "timestamp_ms" }), dereferencedAt: integer("dereferenced_at", { mode: "timestamp_ms" }),
/** 人工备注:缺失补传提示、图片来源说明等 */
userNote: text("user_note"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
}, },
(table) => [ (table) => [

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

1
packages/drizzle-pkg/migrations/0008_media_assets_user_note.sql

@ -0,0 +1 @@
ALTER TABLE `media_assets` ADD `user_note` text;

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -57,6 +57,13 @@
"when": 1776900000000, "when": 1776900000000,
"tag": "0007_vengeful_puck", "tag": "0007_vengeful_puck",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1777000000000,
"tag": "0008_media_assets_user_note",
"breakpoints": true
} }
] ]
} }

35
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 });
});

4
server/api/admin/media/storage-audit.get.ts

@ -4,10 +4,10 @@ import { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
import { getRequestIP } from "h3"; import { getRequestIP } from "h3";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event); const admin = await requireAdmin(event);
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
assertUnderRateLimit(`admin-media-storage-audit:${ip}`, 30, 60_000); 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); return R.success(report);
}); });

17
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 { listUserMediaAssetsPage } from "#server/service/media";
import { parseMeMediaAssetsQuery } from "#server/utils/me-media-assets-query"; import { parseMeMediaAssetsQuery } from "#server/utils/me-media-assets-query";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser(); const user = await event.context.auth.requireUser();
const q = getQuery(event); 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, page: typeof q.page === "string" ? q.page : undefined,
pageSize: typeof q.pageSize === "string" ? q.pageSize : 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({ return R.success({
items: items.map((r) => ({ items: items.map((r) => ({
id: r.id, id: r.id,
storageKey: r.storageKey, storageKey: r.storageKey,
publicPath: "/public/assets/" + r.storageKey, publicPath: `${POST_MEDIA_PUBLIC_PREFIX}${r.storageKey}`,
mime: r.mime, mime: r.mime,
sizeBytes: r.sizeBytes, sizeBytes: r.sizeBytes,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
refCount: r.refCount, refCount: r.refCount,
userNote: r.userNote,
refContexts: r.refContexts,
canDelete: r.canDelete,
deleteBlockedReason: r.deleteBlockedReason,
deleteGraceExpiresAt: r.deleteGraceExpiresAt,
})), })),
total, total,
}); });

14
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 });
});

29
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 });
});

138
server/service/media/index.ts

@ -2,8 +2,10 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { dbGlobal } from "drizzle-pkg/lib/db"; import { dbGlobal } from "drizzle-pkg/lib/db";
import { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content"; import { users } from "drizzle-pkg/lib/schema/auth";
import { and, count, desc, eq, inArray, isNotNull, isNull, lte, not, notExists, or, sql } from "drizzle-orm"; 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 { import {
MEDIA_IMAGE_MAX_WIDTH_PX, MEDIA_IMAGE_MAX_WIDTH_PX,
MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF, MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF,
@ -13,6 +15,7 @@ import {
RELATIVE_ASSETS_DIR, RELATIVE_ASSETS_DIR,
} from "#server/constants/media"; } from "#server/constants/media";
import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs"; 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 { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls";
import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public"; import { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public";
import { nextIntegerId } from "#server/utils/sqlite-id"; import { nextIntegerId } from "#server/utils/sqlite-id";
@ -470,6 +473,46 @@ export async function purgeAllDeletableOrphansGlobally(limit: number): Promise<n
return deleted; return deleted;
} }
function escapeSqlLikePattern(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
/** storage_key、user_note、引用(owner_type / owner_id、文章标题与 slug、资料用户名)任一 LIKE 命中 */
function userMediaAssetsSearchCondition(term: string) {
const pat = `%${escapeSqlLikePattern(term)}%`;
const profileUser = alias(users, "media_search_profile");
const refMatch = exists(
dbGlobal
.select({ one: sql`1` })
.from(mediaRefs)
.leftJoin(
posts,
and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, posts.id)),
)
.leftJoin(
profileUser,
and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_PROFILE), eq(mediaRefs.ownerId, profileUser.id)),
)
.where(
and(
eq(mediaRefs.assetId, mediaAssets.id),
or(
sql`${mediaRefs.ownerType} LIKE ${pat} ESCAPE '\\'`,
sql`CAST(${mediaRefs.ownerId} AS TEXT) LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${posts.title}, '') LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${posts.slug}, '') LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${profileUser.username}, '') LIKE ${pat} ESCAPE '\\'`,
),
),
),
);
return or(
sql`${mediaAssets.storageKey} LIKE ${pat} ESCAPE '\\'`,
sql`COALESCE(${mediaAssets.userNote}, '') LIKE ${pat} ESCAPE '\\'`,
refMatch,
);
}
export type UserMediaAssetListRow = { export type UserMediaAssetListRow = {
id: number; id: number;
storageKey: string; storageKey: string;
@ -477,17 +520,28 @@ export type UserMediaAssetListRow = {
sizeBytes: number; sizeBytes: number;
createdAt: Date; createdAt: Date;
refCount: number; refCount: number;
userNote: string | null;
refContexts: MediaRefContextDto[];
/** 无引用且已过孤儿宽限,可立即从资源库删除 */
canDelete: boolean;
/** 不可删除原因:仍被引用 / 尚在宽限期 */
deleteBlockedReason: "referenced" | "cooling" | null;
/** 宽限结束后可删的预计时间(ISO);仅 cooling 时有值 */
deleteGraceExpiresAt: string | null;
}; };
export async function listUserMediaAssetsPage( export async function listUserMediaAssetsPage(
userId: number, userId: number,
page: number, page: number,
pageSize: number, pageSize: number,
viewer: { userId: number; role: string },
opts?: { search?: string | null },
): Promise<{ items: UserMediaAssetListRow[]; total: number }> { ): Promise<{ items: UserMediaAssetListRow[]; total: number }> {
const [{ total: totalRaw }] = await dbGlobal const term = typeof opts?.search === "string" ? opts.search.trim() : "";
.select({ total: count() }) const userFilter = eq(mediaAssets.userId, userId);
.from(mediaAssets) const whereClause = term ? and(userFilter, userMediaAssetsSearchCondition(term)) : userFilter;
.where(eq(mediaAssets.userId, userId));
const [{ total: totalRaw }] = await dbGlobal.select({ total: count() }).from(mediaAssets).where(whereClause);
const total = typeof totalRaw === "bigint" ? Number(totalRaw) : Number(totalRaw); const total = typeof totalRaw === "bigint" ? Number(totalRaw) : Number(totalRaw);
@ -499,9 +553,12 @@ export async function listUserMediaAssetsPage(
mime: mediaAssets.mime, mime: mediaAssets.mime,
sizeBytes: mediaAssets.sizeBytes, sizeBytes: mediaAssets.sizeBytes,
createdAt: mediaAssets.createdAt, createdAt: mediaAssets.createdAt,
userNote: mediaAssets.userNote,
firstReferencedAt: mediaAssets.firstReferencedAt,
dereferencedAt: mediaAssets.dereferencedAt,
}) })
.from(mediaAssets) .from(mediaAssets)
.where(eq(mediaAssets.userId, userId)) .where(whereClause)
.orderBy(desc(mediaAssets.createdAt)) .orderBy(desc(mediaAssets.createdAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .offset(offset);
@ -526,14 +583,65 @@ export async function listUserMediaAssetsPage(
refMap.set(r.assetId, typeof c === "bigint" ? Number(c) : Number(c)); refMap.set(r.assetId, typeof c === "bigint" ? Number(c) : Number(c));
} }
const items: UserMediaAssetListRow[] = rows.map((row) => ({ const ctxMap = await buildRefContextsForAssets(
id: row.id, rows.map((row) => ({ id: row.id, storageKey: row.storageKey })),
storageKey: row.storageKey, viewer,
mime: row.mime, );
sizeBytes: row.sizeBytes,
createdAt: row.createdAt, const items: UserMediaAssetListRow[] = rows.map((row) => {
refCount: refMap.get(row.id) ?? 0, 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 }; return { items, total };
} }
export async function setMediaAssetUserNote(params: {
assetId: number;
note: string | null;
actorUserId: number;
actorIsAdmin: boolean;
}): Promise<void> {
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));
}

226
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<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)}`;
}

31
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 { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { RELATIVE_ASSETS_DIR } from "#server/constants/media"; import { RELATIVE_ASSETS_DIR } from "#server/constants/media";
import { buildRefContextsForAssets, type MediaRefContextDto } from "#server/service/media/ref-context";
function assetsBaseDir(): string { function assetsBaseDir(): string {
return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR); return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR);
@ -40,6 +41,9 @@ export type StorageAuditMissingRow = {
storageKey: string; storageKey: string;
refCount: number; refCount: number;
refs: StorageAuditRefDetail[]; refs: StorageAuditRefDetail[];
userNote: string | null;
/** 传入 `viewer` 时由审计填充 */
refContexts?: MediaRefContextDto[];
}; };
export type StorageAuditResult = { export type StorageAuditResult = {
@ -51,13 +55,14 @@ export type StorageAuditResult = {
onDiskNotInDb: string[]; onDiskNotInDb: string[];
}; };
export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> { export async function auditMediaStorageVsDb(viewer?: { userId: number; role: string }): Promise<StorageAuditResult> {
const base = assetsBaseDir(); const base = assetsBaseDir();
const rows = await dbGlobal const rows = await dbGlobal
.select({ .select({
id: mediaAssets.id, id: mediaAssets.id,
userId: mediaAssets.userId, userId: mediaAssets.userId,
storageKey: mediaAssets.storageKey, storageKey: mediaAssets.storageKey,
userNote: mediaAssets.userNote,
}) })
.from(mediaAssets); .from(mediaAssets);
@ -103,6 +108,7 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
storageKey: r.storageKey, storageKey: r.storageKey,
refCount, refCount,
refs, refs,
userNote: r.userNote ?? null,
}); });
continue; continue;
} }
@ -115,6 +121,7 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
storageKey: r.storageKey, storageKey: r.storageKey,
refCount, refCount,
refs, refs,
userNote: r.userNote ?? null,
}); });
continue; continue;
} }
@ -125,6 +132,7 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
storageKey: r.storageKey, storageKey: r.storageKey,
refCount, refCount,
refs, refs,
userNote: r.userNote ?? null,
}); });
} }
} }
@ -157,6 +165,11 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
} }
onDiskNotInDb.sort(); onDiskNotInDb.sort();
if (viewer) {
await attachRefContexts(missingOnDisk, viewer);
await attachRefContexts(invalidStorageKey, viewer);
}
return { return {
scannedAt: new Date().toISOString(), scannedAt: new Date().toISOString(),
dbRowCount: rows.length, dbRowCount: rows.length,
@ -167,6 +180,22 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
}; };
} }
async function attachRefContexts(
list: StorageAuditMissingRow[],
viewer: { userId: number; role: string },
): Promise<void> {
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 * media_assets unlink
*/ */

26
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"; import { parseMeMediaAssetsQuery } from "./me-media-assets-query";
describe("parseMeMediaAssetsQuery", () => { describe("parseMeMediaAssetsQuery", () => {
test("defaults: {} -> { page: 1, pageSize: 20 }", () => { test("defaults: {} -> { page: 1, pageSize: 20, search: null }", () => {
expect(parseMeMediaAssetsQuery({})).toEqual({ page: 1, pageSize: 20 }); expect(parseMeMediaAssetsQuery({})).toEqual({ page: 1, pageSize: 20, search: null });
}); });
test("pageSize 10 and 50 are accepted", () => { test("pageSize 10 and 50 are accepted", () => {
expect(parseMeMediaAssetsQuery({ pageSize: "10" })).toEqual({ page: 1, pageSize: 10 }); expect(parseMeMediaAssetsQuery({ pageSize: "10" })).toEqual({ page: 1, pageSize: 10, search: null });
expect(parseMeMediaAssetsQuery({ pageSize: "50" })).toEqual({ page: 1, pageSize: 50 }); 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", () => { test("pageSize 15 throws with statusCode 400", () => {

14
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 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; page: number;
pageSize: number; pageSize: number;
search: string | null;
} { } {
const page = parsePositiveInt(q.page, 1, "page"); const page = parsePositiveInt(q.page, 1, "page");
const pageSizeRaw = parsePositiveInt(q.pageSize, 20, "pageSize"); const pageSizeRaw = parsePositiveInt(q.pageSize, 20, "pageSize");
if (!ALLOWED_PAGE_SIZES.has(pageSizeRaw)) { if (!ALLOWED_PAGE_SIZES.has(pageSizeRaw)) {
throw createError({ statusCode: 400, statusMessage: "pageSize 须为 10、20 或 50" }); 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 };
} }

Loading…
Cancel
Save