From 23ae91da4fefd13358f14353bad29e5fd792363f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Fri, 11 Sep 2026 13:24:30 +0800 Subject: [PATCH] feat: expand brand knowledge upload limits --- .../brand-knowledge/route.ts | 26 +++++++++++++------ .../BrandKnowledgeUploadDialog.tsx | 14 +++++----- .../multipart-route-coverage.test.ts | 4 +-- lib/brand-image-validation.ts | 15 +++++++++-- lib/brand-knowledge-file-types.ts | 5 ++++ scripts/git-incremental-deploy.ps1 | 4 +-- scripts/git-incremental-deploy.test.mjs | 6 ++--- 7 files changed, 51 insertions(+), 23 deletions(-) diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index 1c87ba8..7c7d02a 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -10,16 +10,22 @@ import { getUploadRootDir } from '@/lib/upload-paths' import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security' import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard' import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits' -import { validateBrandImage, MAX_BRAND_IMAGE_BYTES } from '@/lib/brand-image-validation' -import { BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS, BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types' +import { validateBrandImage } from '@/lib/brand-image-validation' +import { + BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS, + BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES, + BRAND_KNOWLEDGE_MAX_REQUEST_BYTES, + BRAND_KNOWLEDGE_TYPE_OPTIONS, +} from '@/lib/brand-knowledge-file-types' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const revalidate = 0 -const MAX_FILE_SIZE = 50 * 1024 * 1024 -const MAX_REQUEST_SIZE = 58 * 1024 * 1024 -const MAX_PREVIEW_IMAGE_SIZE = MAX_BRAND_IMAGE_BYTES +const MAX_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES +const MAX_REQUEST_SIZE = BRAND_KNOWLEDGE_MAX_REQUEST_BYTES +const MAX_PREVIEW_IMAGE_SIZE = BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES const KNOWLEDGE_TYPES: ReadonlySet = new Set(BRAND_KNOWLEDGE_TYPE_OPTIONS) // The source file stays independent from Material/MaterialAsset rows. const ALLOWED_EXTENSIONS: ReadonlySet = new Set(BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS) @@ -157,16 +163,20 @@ export async function POST(request: Request) { const productCategoryName = normalizeText(formData.get('productCategoryName'), 80) if (!title) return NextResponse.json({ success: false, error: '请填写知识标题' }, { status: 400, headers: PRIVATE_HEADERS }) if (!KNOWLEDGE_TYPES.has(knowledgeType)) return NextResponse.json({ success: false, error: '知识类型不合法' }, { status: 400, headers: PRIVATE_HEADERS }) - if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '文件大小需在 1B 至 50MB 之间' }, { status: 400, headers: PRIVATE_HEADERS }) + if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS }) let validatedPreview: Awaited> | null = null let previewImageName: string | null = null if (previewImage instanceof File) { if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) { - return NextResponse.json({ success: false, error: '预览图需为 1B 至 5MB 之间的 PNG、JPEG 或 WebP 图片' }, { status: 400, headers: PRIVATE_HEADERS }) + return NextResponse.json({ success: false, error: '预览图需为 1B 至 50MB 之间的 PNG、JPEG 或 WebP 图片' }, { status: 400, headers: PRIVATE_HEADERS }) } try { - validatedPreview = await validateBrandImage(Buffer.from(await previewImage.arrayBuffer()), previewImage.type) + validatedPreview = await validateBrandImage( + Buffer.from(await previewImage.arrayBuffer()), + previewImage.type, + { maxBytes: MAX_PREVIEW_IMAGE_SIZE, maxBytesLabel: '50MB' }, + ) } catch (error) { return NextResponse.json( { success: false, error: error instanceof Error ? error.message : '预览图无效,请上传 PNG、JPEG 或 WebP 图片' }, diff --git a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx index 5fd2f9b..e22df48 100644 --- a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx +++ b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx @@ -6,13 +6,15 @@ import { AlertCircle, FileText, FolderOpen, ImagePlus, Upload, X } from 'lucide- import { BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT, BRAND_KNOWLEDGE_MAIN_FILE_LABEL, + BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES, BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT, BRAND_KNOWLEDGE_TYPE_OPTIONS, } from '@/lib/brand-knowledge-file-types' const BRAND_OPTIONS = ['未关联品牌', '亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美'] -const MAX_MAIN_FILE_SIZE = 50 * 1024 * 1024 -const MAX_PREVIEW_SIZE = 5 * 1024 * 1024 +const MAX_MAIN_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES +const MAX_PREVIEW_SIZE = BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES const PREVIEW_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']) interface ProductCategory { id: string; name: string } @@ -106,7 +108,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return return } if (file.size <= 0 || file.size > MAX_MAIN_FILE_SIZE) { - setErrorMessage('主文件需为 1B 至 50MB 之间的有效文件') + setErrorMessage('主文件需为 1B 至 1GB 之间的有效文件') return } setErrorMessage('') @@ -121,7 +123,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return return } if (file.size <= 0 || file.size > MAX_PREVIEW_SIZE) { - setErrorMessage('预览图需为 1B 至 5MB 之间的有效图片') + setErrorMessage('预览图需为 1B 至 50MB 之间的有效图片') return } if (previewUrl) URL.revokeObjectURL(previewUrl) @@ -171,12 +173,12 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return

上传知识

补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。

-
+
-

预览图片 (可选)

{previewImage && }
event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ?
{`预览图:${previewImage.name}`}

{previewImage.name}

{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览

:

拖放预览图片到这里,或选择图片

支持 JPG、PNG、WebP,单张不超过 5MB

}
+

预览图片 (可选)

{previewImage && }
event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ?
{`预览图:${previewImage.name}`}

{previewImage.name}

{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览

:

拖放预览图片到这里,或选择图片

支持 JPG、PNG、WebP,单张不超过 50MB

}

知识文件

{selectedFile && }
event.preventDefault()} onDragLeave={handleDragLeave} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); setFile(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ?

{selectedFile.name}

{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库

:

拖放文件到这里,或选择文件

支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}

}
diff --git a/lib/__tests__/multipart-route-coverage.test.ts b/lib/__tests__/multipart-route-coverage.test.ts index 70a207c..2483f82 100644 --- a/lib/__tests__/multipart-route-coverage.test.ts +++ b/lib/__tests__/multipart-route-coverage.test.ts @@ -60,7 +60,7 @@ describe('multipart routes are bounded before parsing', () => { const requiredFragments = [ 'location = /api/upload/preview {\n client_max_body_size 53m;', - 'location = /api/department-dashboard/brand-knowledge {\n client_max_body_size 53m;', + 'location = /api/department-dashboard/brand-knowledge {\n client_max_body_size 1100m;', 'location ~ "^/api/upload/s3(?:-original)?\\$" {\n client_max_body_size 103m;', 'location = /api/materials/update {\n client_max_body_size 11m;', 'location ~ "^/api/(?:user/avatar|system-settings/upload-logo)\\$" {\n client_max_body_size 7m;', @@ -71,7 +71,7 @@ describe('multipart routes are bounded before parsing', () => { expect(source).toContain(fragment) } - const boundedLocations = source.match(/client_max_body_size (?:7|11|53|103|106|131)m;[\s\S]{0,180}?proxy_request_buffering off;/g) || [] + const boundedLocations = source.match(/client_max_body_size (?:7|11|53|103|106|131|1100)m;[\s\S]{0,180}?proxy_request_buffering off;/g) || [] expect(boundedLocations.length).toBeGreaterThanOrEqual(7) }) }) diff --git a/lib/brand-image-validation.ts b/lib/brand-image-validation.ts index 38601b5..3393bf4 100644 --- a/lib/brand-image-validation.ts +++ b/lib/brand-image-validation.ts @@ -11,6 +11,11 @@ export type ValidatedBrandImage = { height: number } +export type BrandImageValidationOptions = { + maxBytes?: number + maxBytesLabel?: string +} + const FORMAT_DETAILS: Record> = { png: { extension: 'png', contentType: 'image/png' }, jpeg: { extension: 'jpg', contentType: 'image/jpeg' }, @@ -24,9 +29,15 @@ const FORMAT_DETAILS: Record { - if (buffer.length <= 0 || buffer.length > MAX_BRAND_IMAGE_BYTES) { - throw new Error('品牌图片必须是 5MB 以内的有效文件') + const maxBytes = options.maxBytes ?? MAX_BRAND_IMAGE_BYTES + const maxBytesLabel = options.maxBytesLabel ?? '5MB' + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new TypeError('品牌图片大小上限必须是正的安全整数') + } + if (buffer.length <= 0 || buffer.length > maxBytes) { + throw new Error(`品牌图片必须是 ${maxBytesLabel} 以内的有效文件`) } const image = sharp(buffer, { diff --git a/lib/brand-knowledge-file-types.ts b/lib/brand-knowledge-file-types.ts index 320a3d2..e120dda 100644 --- a/lib/brand-knowledge-file-types.ts +++ b/lib/brand-knowledge-file-types.ts @@ -13,6 +13,11 @@ export const BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT = BRAND_KNOWLEDGE_MAIN_FILE_EXTENS export const BRAND_KNOWLEDGE_MAIN_FILE_LABEL = 'PDF、Office、表格、Markdown、文本、压缩包、PSD/TIF/PSB、音视频和图片文件' +export const BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES = 1024 * 1024 * 1024 +export const BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES = 50 * 1024 * 1024 +// Includes multipart framing headroom for a 1 GiB main file and 50 MiB preview. +export const BRAND_KNOWLEDGE_MAX_REQUEST_BYTES = 1100 * 1024 * 1024 + export const BRAND_KNOWLEDGE_PREVIEW_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const export const BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT = BRAND_KNOWLEDGE_PREVIEW_IMAGE_EXTENSIONS.join(',') diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index fb43d75..2adfdf1 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -2004,11 +2004,11 @@ http { proxy_buffering off; } - # Brand knowledge accepts one 50 MiB document plus multipart framing. + # Brand knowledge accepts one 1 GiB document and a 50 MiB preview plus multipart framing. # Keep this allowance scoped to the exact collection endpoint so list and # upload requests work without widening unrelated dashboard routes. location = /api/department-dashboard/brand-knowledge { - client_max_body_size 58m; + client_max_body_size 1100m; proxy_request_buffering off; proxy_pass http://app_backend; proxy_hide_header X-Material-Proxy-Target; diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index cff3204..2c4fd8a 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -79,7 +79,7 @@ test('brand knowledge upload stays bounded in the route and stable proxy', () => assert.match( deploySource, - /location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 58m;\n\s+proxy_request_buffering off;/, + /location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 1100m;\n\s+proxy_request_buffering off;/, ) const headerGuard = routeSource.indexOf('assertBoundedMultipartHeaders(') @@ -90,7 +90,7 @@ test('brand knowledge upload stays bounded in the route and stable proxy', () => assert.ok(acquireSlot > headerGuard) assert.ok(parseBody > acquireSlot) assert.ok(parsedFiles > parseBody) - assert.match(routeSource, /const MAX_FILE_SIZE = 50 \* 1024 \* 1024/) - assert.match(routeSource, /const MAX_REQUEST_SIZE = 58 \* 1024 \* 1024/) + assert.match(routeSource, /BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES/) + assert.match(routeSource, /BRAND_KNOWLEDGE_MAX_REQUEST_BYTES/) assert.match(routeSource, /releaseRequestSlot\?\.\(\)/) })