Browse Source

feat(media): re-upload missing files in place for same storage_key

Made-with: Cursor
tags/邮箱功能前置
npmrun 3 weeks ago
parent
commit
967d738c7e
  1. 80
      app/pages/me/admin/media-storage.vue
  2. 82
      app/pages/me/media/index.vue
  3. 87
      server/api/admin/media/assets/[id]/reupload.post.ts
  4. 83
      server/api/me/media/assets/[id]/reupload.post.ts
  5. 80
      server/service/media/index.ts

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

@ -37,7 +37,7 @@ function formatRefSources(refs: RefDetail[]): string {
} }
const { user, refresh } = useAuthSession() const { user, refresh } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData, getApiErrorMessage } = useClientApi()
const toast = useToast() const toast = useToast()
const loading = ref(false) const loading = ref(false)
@ -45,6 +45,10 @@ const cleaning = ref(false)
const report = ref<AuditReport | null>(null) const report = ref<AuditReport | null>(null)
const cleanupOpen = ref(false) const cleanupOpen = ref(false)
const reuploadInput = ref<HTMLInputElement | null>(null)
const reuploadTargetId = ref<number | null>(null)
const reuploadingId = ref<number | null>(null)
async function ensureAdmin() { async function ensureAdmin() {
await refresh(true) await refresh(true)
if (user.value?.role !== 'admin') { if (user.value?.role !== 'admin') {
@ -52,16 +56,50 @@ async function ensureAdmin() {
} }
} }
async function runAudit() { async function runAudit(showToast = true) {
loading.value = true loading.value = true
try { try {
report.value = await fetchData<AuditReport>('/api/admin/media/storage-audit') report.value = await fetchData<AuditReport>('/api/admin/media/storage-audit', { notify: false })
if (showToast) {
toast.add({ title: '校验完成', color: 'success' }) toast.add({ title: '校验完成', color: 'success' })
}
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function openReuploadPicker(assetId: number) {
reuploadTargetId.value = assetId
nextTick(() => reuploadInput.value?.click())
}
async function onReuploadFile(ev: Event) {
const assetId = reuploadTargetId.value
reuploadTargetId.value = null
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!assetId || !file) {
return
}
reuploadingId.value = assetId
try {
const form = new FormData()
form.append('file', file)
await fetchData<{ url: string; sizeBytes: number }>(`/api/admin/media/assets/${assetId}/reupload`, {
method: 'POST',
body: form,
notify: false,
})
toast.add({ title: '已重新写入磁盘', color: 'success' })
await runAudit(false)
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
reuploadingId.value = null
}
}
async function runCleanup() { async function runCleanup() {
cleaning.value = true cleaning.value = true
try { try {
@ -103,6 +141,13 @@ onMounted(async () => {
<template> <template>
<UContainer class="py-8 space-y-6 w-full max-w-[min(100%,88rem)] px-4 sm:px-6"> <UContainer class="py-8 space-y-6 w-full max-w-[min(100%,88rem)] px-4 sm:px-6">
<input
ref="reuploadInput"
type="file"
class="sr-only"
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="onReuploadFile"
>
<div class="flex flex-wrap justify-between items-start gap-3"> <div class="flex flex-wrap justify-between items-start gap-3">
<div> <div>
<h1 class="text-2xl font-semibold"> <h1 class="text-2xl font-semibold">
@ -112,6 +157,7 @@ onMounted(async () => {
比对 <code class="text-xs">public/assets</code> 与表 <code class="text-xs">media_assets</code> 比对 <code class="text-xs">public/assets</code> 与表 <code class="text-xs">media_assets</code>
库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件 库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件
一键清理仅删除<strong> media_refs 引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件 一键清理仅删除<strong> media_refs 引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件
对有引用但缺文件的记录可使用<strong>重新上传</strong>按原 <code class="text-xs">storage_key</code> 写回 WebP不破坏文章/资料中的链接
</p> </p>
</div> </div>
<UButton to="/me" variant="ghost" size="sm"> <UButton to="/me" variant="ghost" size="sm">
@ -167,6 +213,9 @@ onMounted(async () => {
<th class="p-2 min-w-[12rem]"> <th class="p-2 min-w-[12rem]">
引用来源owner_type 引用来源owner_type
</th> </th>
<th class="p-2 w-28 whitespace-nowrap">
操作
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -186,6 +235,17 @@ onMounted(async () => {
<td class="p-2 text-xs align-top"> <td class="p-2 text-xs align-top">
{{ formatRefSources(row.refs) }} {{ formatRefSources(row.refs) }}
</td> </td>
<td class="p-2 align-top">
<UButton
size="xs"
variant="soft"
:loading="reuploadingId === row.id"
:disabled="loading || reuploadingId !== null"
@click="openReuploadPicker(row.id)"
>
重新上传
</UButton>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -216,6 +276,9 @@ onMounted(async () => {
<th class="p-2 min-w-[12rem]"> <th class="p-2 min-w-[12rem]">
引用来源owner_type 引用来源owner_type
</th> </th>
<th class="p-2 w-28 whitespace-nowrap">
操作
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -236,6 +299,17 @@ onMounted(async () => {
<span v-if="row.refCount === 0" class="text-muted"></span> <span v-if="row.refCount === 0" class="text-muted"></span>
<span v-else>{{ formatRefSources(row.refs) }}</span> <span v-else>{{ formatRefSources(row.refs) }}</span>
</td> </td>
<td class="p-2 align-top">
<UButton
size="xs"
variant="soft"
:loading="reuploadingId === row.id"
:disabled="loading || reuploadingId !== null"
@click="openReuploadPicker(row.id)"
>
重新上传
</UButton>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

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

@ -38,6 +38,12 @@ const total = ref(0)
const loading = ref(true) const loading = ref(true)
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 reuploadTargetId = ref<number | null>(null)
const reuploadingId = ref<number | null>(null)
/** 预览加载失败(多为磁盘缺文件) */
const previewFailedIds = ref<Set<number>>(new Set())
const imgBust = ref<Record<number, number>>({})
const pageSizeItems = [ const pageSizeItems = [
{ label: '每页 10', value: 10 }, { label: '每页 10', value: 10 },
@ -76,6 +82,8 @@ async function load() {
) )
items.value = body.items items.value = body.items
total.value = body.total total.value = body.total
const ids = new Set(body.items.map((i) => i.id))
previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => ids.has(id)))
} catch (e: unknown) { } catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' }) toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally { } finally {
@ -144,6 +152,49 @@ async function copyUrl(item: MediaAssetRow) {
} }
} }
function imageSrc(item: MediaAssetRow): string {
const b = imgBust.value[item.id]
return b ? `${item.publicPath}?v=${b}` : item.publicPath
}
function onImgError(id: number) {
previewFailedIds.value = new Set([...previewFailedIds.value, id])
}
function openReuploadPicker(assetId: number) {
reuploadTargetId.value = assetId
nextTick(() => reuploadInput.value?.click())
}
async function onReuploadFile(ev: Event) {
const assetId = reuploadTargetId.value
reuploadTargetId.value = null
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!assetId || !file) {
return
}
reuploadingId.value = assetId
try {
const form = new FormData()
form.append('file', file)
await fetchData<{ url: string; sizeBytes: number }>(`/api/me/media/assets/${assetId}/reupload`, {
method: 'POST',
body: form,
notify: false,
})
previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => id !== assetId))
imgBust.value = { ...imgBust.value, [assetId]: Date.now() }
toast.add({ title: '已重新写入磁盘', color: 'success' })
await load()
} catch (e: unknown) {
toast.add({ title: getApiErrorMessage(e), color: 'error' })
} finally {
reuploadingId.value = null
}
}
async function copyMarkdown(item: MediaAssetRow) { async function copyMarkdown(item: MediaAssetRow) {
if (!import.meta.client) { if (!import.meta.client) {
return return
@ -161,6 +212,13 @@ async function copyMarkdown(item: MediaAssetRow) {
<template> <template>
<div class="space-y-6 w-full max-w-[min(100%,88rem)] px-0 sm:px-0"> <div class="space-y-6 w-full max-w-[min(100%,88rem)] px-0 sm:px-0">
<input
ref="reuploadInput"
type="file"
class="sr-only"
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="onReuploadFile"
>
<div> <div>
<h2 class="text-lg font-medium"> <h2 class="text-lg font-medium">
资源库 资源库
@ -203,12 +261,32 @@ async function copyMarkdown(item: MediaAssetRow) {
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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">
<img <img
:src="item.publicPath" :src="imageSrc(item)"
:alt="item.storageKey" :alt="item.storageKey"
class="w-full aspect-video object-cover rounded-md border border-default" class="w-full h-full object-cover"
loading="lazy" loading="lazy"
@error="onImgError(item.id)"
/>
<div
v-if="previewFailedIds.has(item.id)"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-default/80 p-2"
> >
<p class="text-xs text-muted text-center">
文件缺失或无法加载
</p>
<UButton
size="xs"
color="primary"
:loading="reuploadingId === item.id"
:disabled="reuploadingId !== null && reuploadingId !== item.id"
@click="openReuploadPicker(item.id)"
>
重新上传
</UButton>
</div>
</div>
<div <div
class="truncate font-mono text-xs text-default" class="truncate font-mono text-xs text-default"
:title="item.storageKey" :title="item.storageKey"

87
server/api/admin/media/assets/[id]/reupload.post.ts

@ -0,0 +1,87 @@
import multer from "multer";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { callNodeListener, getRequestIP } from "h3";
import { POST_MEDIA_PUBLIC_PREFIX } from "#server/constants/media";
import { replaceMediaAssetFileFromTempUpload } 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";
type MulterUploadedFile = {
originalname: string;
filename: string;
path: string;
mimetype: string;
size: number;
};
export default defineWrappedResponseHandler(async (event) => {
const admin = await requireAdmin(event);
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
assertUnderRateLimit(`admin-media-asset-reupload:${ip}`, 40, 60_000);
const idRaw = getRouterParam(event, "id");
const assetId = Number(idRaw);
if (!Number.isInteger(assetId) || assetId < 1) {
throw createError({ statusCode: 400, statusMessage: "无效的资源 id" });
}
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, os.tmpdir());
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const baseName = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, "-");
cb(null, `reupload-${Date.now()}-${Math.round(Math.random() * 1e9)}-${baseName}${ext}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("只支持 PNG/JPG/WebP 格式图片"));
}
},
});
await callNodeListener(
// @ts-expect-error multer 中间件类型
upload.single("file"),
event.node.req,
event.node.res,
);
// @ts-expect-error multer 挂载 file
const file = event.node.req.file as MulterUploadedFile | undefined;
if (!file?.path) {
throw createError({ statusCode: 400, statusMessage: "请选择要上传的图片" });
}
try {
const result = await replaceMediaAssetFileFromTempUpload({
assetId,
actorUserId: admin.id,
actorIsAdmin: true,
tempInputPath: file.path,
});
return R.success({
url: `${POST_MEDIA_PUBLIC_PREFIX}${result.storageKey}`,
sizeBytes: result.sizeBytes,
});
} catch (err) {
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch {
/* ignore */
}
}
throw err;
}
});

83
server/api/me/media/assets/[id]/reupload.post.ts

@ -0,0 +1,83 @@
import multer from "multer";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { callNodeListener } from "h3";
import { POST_MEDIA_PUBLIC_PREFIX } from "#server/constants/media";
import { replaceMediaAssetFileFromTempUpload } from "#server/service/media";
import { R } from "#server/utils/response";
type MulterUploadedFile = {
originalname: string;
filename: string;
path: string;
mimetype: string;
size: number;
};
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 upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, os.tmpdir());
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const baseName = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, "-");
cb(null, `reupload-${Date.now()}-${Math.round(Math.random() * 1e9)}-${baseName}${ext}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("只支持 PNG/JPG/WebP 格式图片"));
}
},
});
await callNodeListener(
// @ts-expect-error multer 中间件类型
upload.single("file"),
event.node.req,
event.node.res,
);
// @ts-expect-error multer 挂载 file
const file = event.node.req.file as MulterUploadedFile | undefined;
if (!file?.path) {
throw createError({ statusCode: 400, statusMessage: "请选择要上传的图片" });
}
try {
const result = await replaceMediaAssetFileFromTempUpload({
assetId,
actorUserId: user.id,
actorIsAdmin: false,
tempInputPath: file.path,
});
return R.success({
url: `${POST_MEDIA_PUBLIC_PREFIX}${result.storageKey}`,
sizeBytes: result.sizeBytes,
});
} catch (err) {
if (file.path && fs.existsSync(file.path)) {
try {
fs.unlinkSync(file.path);
} catch {
/* ignore */
}
}
throw err;
}
});

80
server/service/media/index.ts

@ -1,11 +1,14 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
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 { 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 { and, count, desc, eq, inArray, isNotNull, isNull, lte, not, notExists, or, sql } from "drizzle-orm";
import { import {
MEDIA_IMAGE_MAX_WIDTH_PX,
MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF, MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF,
MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF, MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF,
MEDIA_WEBP_QUALITY,
POST_MEDIA_PUBLIC_PREFIX, POST_MEDIA_PUBLIC_PREFIX,
RELATIVE_ASSETS_DIR, RELATIVE_ASSETS_DIR,
} from "#server/constants/media"; } from "#server/constants/media";
@ -144,6 +147,83 @@ export async function insertMediaAssetRow(params: {
return id; return id;
} }
/**
* WebP `storageKey` `sizeBytes` / `mime`
* `id` `storageKey`
*/
export async function replaceMediaAssetFileFromTempUpload(params: {
assetId: number;
actorUserId: number;
actorIsAdmin: boolean;
tempInputPath: string;
}): Promise<{ storageKey: string; sizeBytes: number }> {
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 key = row.storageKey;
if (!key || key !== path.basename(key) || key.includes("..")) {
throw createError({ statusCode: 400, statusMessage: "非法 storage_key,无法覆盖写入" });
}
const base = assetsBaseDir();
const destPath = path.resolve(base, key);
if (!isPathUnderAssetsDir(destPath)) {
throw createError({ statusCode: 400, statusMessage: "非法存储路径" });
}
if (!fs.existsSync(base)) {
fs.mkdirSync(base, { recursive: true });
}
try {
await sharp(params.tempInputPath)
.rotate()
.resize({
width: MEDIA_IMAGE_MAX_WIDTH_PX,
height: MEDIA_IMAGE_MAX_WIDTH_PX,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: MEDIA_WEBP_QUALITY })
.toFile(destPath);
} catch (procErr) {
if (fs.existsSync(destPath)) {
try {
fs.unlinkSync(destPath);
} catch {
/* ignore */
}
}
const msg = procErr instanceof Error ? procErr.message : "图片处理失败";
throw createError({ statusCode: 400, statusMessage: msg });
} finally {
if (params.tempInputPath !== destPath) {
try {
if (fs.existsSync(params.tempInputPath)) {
fs.unlinkSync(params.tempInputPath);
}
} catch {
/* ignore */
}
}
}
const stat = fs.statSync(destPath);
await dbGlobal
.update(mediaAssets)
.set({
sizeBytes: stat.size,
mime: "image/webp",
sha256: null,
variantsJson: null,
})
.where(eq(mediaAssets.id, params.assetId));
return { storageKey: key, sizeBytes: stat.size };
}
export async function reconcileAssetTimestampsAfterRefChange(assetIds: number[]): Promise<void> { export async function reconcileAssetTimestampsAfterRefChange(assetIds: number[]): Promise<void> {
const unique = [...new Set(assetIds)]; const unique = [...new Set(assetIds)];
for (const id of unique) { for (const id of unique) {

Loading…
Cancel
Save