From 66560b60a593933f5fef35817eb21d3eaf61b146 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 28 Aug 2026 18:32:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=AB=98=E6=B8=85?= =?UTF-8?q?=E5=A4=A7=E5=9B=BE=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + app/components/RichTextEditor.vue | 2 +- app/pages/admin/media.vue | 2 +- docs/部署与恢复手册.md | 10 ++++++++++ server/api/admin/media.post.ts | 13 +++++++------ server/utils/image-validation.ts | 10 ++++++---- tests/image-validation.test.ts | 6 ++++-- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7e61448..6a6709b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ sh scripts/backup.sh - 修改会话密钥和后台初始密码。 - 配置域名、HTTPS 和反向代理。 +- 反向代理的请求体上限至少设置为 `20m`,否则高清图片会在到达应用前被代理拒绝。 - 域名启用 HTTPS 后,把 `NUXT_SESSION_COOKIE_SECURE` 改为 `true`。 - 配置每日备份及恢复演练。 - 将 `.env.docker` 仅保存在服务器,不提交版本库。 diff --git a/app/components/RichTextEditor.vue b/app/components/RichTextEditor.vue index 07afb37..0c1296d 100644 --- a/app/components/RichTextEditor.vue +++ b/app/components/RichTextEditor.vue @@ -168,7 +168,7 @@ async function uploadImage(event: Event) { - + diff --git a/app/pages/admin/media.vue b/app/pages/admin/media.vue index 1fefc0d..ff002d0 100644 --- a/app/pages/admin/media.vue +++ b/app/pages/admin/media.vue @@ -90,7 +90,7 @@ const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.ceil(bytes
正在加载素材…
-
上传新图片支持 JPG、PNG、WebP;最大 8MB、4000 万像素。Logo 建议使用透明 PNG,宽度 600–1600px
+
上传新图片支持 JPG、PNG、WebP;最大 20MB、6400 万像素,最长边不超过 12000px。系统保留原图并生成最长边 3200px 的高清展示版
diff --git a/docs/部署与恢复手册.md b/docs/部署与恢复手册.md index 9749861..de5c49b 100644 --- a/docs/部署与恢复手册.md +++ b/docs/部署与恢复手册.md @@ -34,6 +34,16 @@ docker compose logs --tail=100 web 容器必须显示 `healthy`。随后通过 Nginx 配置域名、HTTPS、请求体大小限制与访问日志。 +图片上传允许单张最大 20 MB。Nginx 站点配置需要同步设置请求体上限,并为大图处理保留合理超时: + +```nginx +client_max_body_size 20m; +proxy_read_timeout 120s; +proxy_send_timeout 120s; +``` + +修改后执行 `nginx -t`,确认通过再平滑重载 Nginx。若使用其他反向代理,也要把对应上传上限设置为至少 20 MB。 + ## 3. 数据目录 - SQLite:Docker 数据卷中的 `/app/data/db/huiyu.sqlite` diff --git a/server/api/admin/media.post.ts b/server/api/admin/media.post.ts index 61d123a..5df40a2 100644 --- a/server/api/admin/media.post.ts +++ b/server/api/admin/media.post.ts @@ -3,7 +3,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises' import { extname } from 'node:path' import sharp from 'sharp' import { z } from 'zod' -import { MAX_IMAGE_BYTES, MAX_IMAGE_HEIGHT, MAX_IMAGE_PIXELS, MAX_IMAGE_WIDTH, isSharpPixelLimitError } from '../../utils/image-validation' +import { LARGE_IMAGE_EDGE, MAX_IMAGE_BYTES, MAX_IMAGE_HEIGHT, MAX_IMAGE_MB, MAX_IMAGE_PIXELS, MAX_IMAGE_WIDTH, isSharpPixelLimitError } from '../../utils/image-validation' import { mediaUrl, safeUploadPath, serializeMedia, type MediaAssetRow } from '../../utils/media' import { useDatabase } from '../../utils/db' @@ -19,6 +19,7 @@ const metadataSchema = z.object({ copyright: z.string().trim().max(200).default(''), authorizationStatus: z.enum(['pending', 'authorized', 'restricted']).default('pending'), }) +const pixelLimitLabel = `${MAX_IMAGE_PIXELS / 10_000} 万像素` function detectImageType(data: Buffer) { if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg' @@ -31,7 +32,7 @@ export default defineEventHandler(async (event) => { const parts = await readMultipartFormData(event) const file = parts?.find(part => part.name === 'file' && part.filename) if (!file?.data?.length) throw createError({ statusCode: 400, statusMessage: '请选择图片文件' }) - if (file.data.length > MAX_IMAGE_BYTES) throw createError({ statusCode: 413, statusMessage: '单张图片不能超过 8MB' }) + if (file.data.length > MAX_IMAGE_BYTES) throw createError({ statusCode: 413, statusMessage: `单张图片不能超过 ${MAX_IMAGE_MB}MB` }) const detectedType = detectImageType(file.data) if (!detectedType || !(detectedType in allowedTypes)) throw createError({ statusCode: 415, statusMessage: '仅支持 JPG、PNG、WebP 图片' }) @@ -43,14 +44,14 @@ export default defineEventHandler(async (event) => { try { imageMetadata = await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).metadata() } catch (error) { if (isSharpPixelLimitError(error)) { - throw createError({ statusCode: 413, statusMessage: '图片像素过大,请压缩至 10000×10000 且不超过 4000 万像素后上传' }) + throw createError({ statusCode: 413, statusMessage: `图片像素过大,请压缩至 ${MAX_IMAGE_WIDTH}×${MAX_IMAGE_HEIGHT} 且不超过 ${pixelLimitLabel} 后上传` }) } throw createError({ statusCode: 415, statusMessage: '图片内容损坏或无法解析' }) } const width = imageMetadata.width || 0 const height = imageMetadata.height || 0 if (!width || !height || width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT || width * height > MAX_IMAGE_PIXELS) { - throw createError({ statusCode: 413, statusMessage: '图片尺寸不能超过 10000×10000 或 4000 万像素' }) + throw createError({ statusCode: 413, statusMessage: `图片尺寸不能超过 ${MAX_IMAGE_WIDTH}×${MAX_IMAGE_HEIGHT} 或 ${pixelLimitLabel}` }) } const fields = Object.fromEntries((parts || []).filter(part => !part.filename && part.name).map(part => [part.name!, part.data.toString('utf8')])) @@ -66,8 +67,8 @@ export default defineEventHandler(async (event) => { await Promise.all(writtenPaths.map(path => mkdir(safeUploadPath(path).replace(/\/[^/]+$/, ''), { recursive: true }))) try { await writeFile(safeUploadPath(originalPath), file.data, { flag: 'wx' }) - await sharp(file.data).rotate().resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true }).webp({ quality: 84 }).toFile(safeUploadPath(derivedPath)) - await sharp(file.data).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(safeUploadPath(thumbnailPath)) + await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).rotate().resize({ width: LARGE_IMAGE_EDGE, height: LARGE_IMAGE_EDGE, fit: 'inside', withoutEnlargement: true }).webp({ quality: 88 }).toFile(safeUploadPath(derivedPath)) + await sharp(file.data, { limitInputPixels: MAX_IMAGE_PIXELS }).rotate().resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true }).webp({ quality: 80 }).toFile(safeUploadPath(thumbnailPath)) const timestamp = now.toISOString() const db = useDatabase() diff --git a/server/utils/image-validation.ts b/server/utils/image-validation.ts index 462a1b3..2784fa7 100644 --- a/server/utils/image-validation.ts +++ b/server/utils/image-validation.ts @@ -1,7 +1,9 @@ -export const MAX_IMAGE_BYTES = 8 * 1024 * 1024 -export const MAX_IMAGE_WIDTH = 10_000 -export const MAX_IMAGE_HEIGHT = 10_000 -export const MAX_IMAGE_PIXELS = 40_000_000 +export const MAX_IMAGE_MB = 20 +export const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024 +export const MAX_IMAGE_WIDTH = 12_000 +export const MAX_IMAGE_HEIGHT = 12_000 +export const MAX_IMAGE_PIXELS = 64_000_000 +export const LARGE_IMAGE_EDGE = 3_200 export function isSharpPixelLimitError(error: unknown) { return error instanceof Error && /exceeds pixel limit/i.test(error.message) diff --git a/tests/image-validation.test.ts b/tests/image-validation.test.ts index 6fd77e2..f92bbaa 100644 --- a/tests/image-validation.test.ts +++ b/tests/image-validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { MAX_IMAGE_PIXELS, isSharpPixelLimitError } from '../server/utils/image-validation' +import { LARGE_IMAGE_EDGE, MAX_IMAGE_BYTES, MAX_IMAGE_PIXELS, isSharpPixelLimitError } from '../server/utils/image-validation' describe('图片上传校验', () => { it('识别 Sharp 像素上限错误', () => { @@ -8,6 +8,8 @@ describe('图片上传校验', () => { it('不把普通解析错误误判为像素过大', () => { expect(isSharpPixelLimitError(new Error('Input buffer contains unsupported image format'))).toBe(false) - expect(MAX_IMAGE_PIXELS).toBe(40_000_000) + expect(MAX_IMAGE_BYTES).toBe(20 * 1024 * 1024) + expect(MAX_IMAGE_PIXELS).toBe(64_000_000) + expect(LARGE_IMAGE_EDGE).toBe(3_200) }) })