feat: expand brand knowledge upload limits

This commit is contained in:
江林铖 2026-09-11 13:24:30 +08:00
parent 51d80148c3
commit 23ae91da4f
7 changed files with 51 additions and 23 deletions

View File

@ -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<string> = new Set(BRAND_KNOWLEDGE_TYPE_OPTIONS)
// The source file stays independent from Material/MaterialAsset rows.
const ALLOWED_EXTENSIONS: ReadonlySet<string> = 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<ReturnType<typeof validateBrandImage>> | 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 图片' },

View File

@ -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
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6"><div className="flex items-start justify-between gap-5"><div className="min-w-0"><h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。</p></div><button ref={closeButtonRef} type="button" onClick={onClose} aria-label="关闭上传知识窗口" title="关闭" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><X className="h-5 w-5" aria-hidden="true" /></button></div></header>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-6 sm:px-8">
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 50MB;预览图支持 JPG、PNG、WebP,不超过 5MB。</p>}</div></div>
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB。</p>}</div></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>
<section aria-labelledby="brand-knowledge-preview-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-preview-label" className="text-sm font-semibold text-[#101828]">预览图片 <span className="font-normal text-[#66758b]">(可选)</span></h3>{previewImage && <button type="button" onClick={removePreview} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除图片</button>}</div><div onDragEnter={handlePreviewDragEnter} onDragOver={(event) => 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]'}`}><input ref={previewInputRef} type="file" aria-label="选择预览图片" accept={BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT} onChange={(event) => setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ? <div className="flex items-center gap-4"><div className="relative h-28 w-40 shrink-0 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white"><Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-cover" /></div><div className="min-w-0"><p className="break-all text-sm font-semibold text-[#101828]">{previewImage.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换图片</button></div></div> : <div className="flex flex-col items-center py-4 text-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><ImagePlus className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放预览图片到这里,或选择图片</p><p className="mt-1 text-sm text-[#66758b]">支持 JPG、PNG、WebP,单张不超过 5MB</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择图片</button></div>}</div></section>
<section aria-labelledby="brand-knowledge-preview-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-preview-label" className="text-sm font-semibold text-[#101828]">预览图片 <span className="font-normal text-[#66758b]">(可选)</span></h3>{previewImage && <button type="button" onClick={removePreview} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除图片</button>}</div><div onDragEnter={handlePreviewDragEnter} onDragOver={(event) => 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]'}`}><input ref={previewInputRef} type="file" aria-label="选择预览图片" accept={BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT} onChange={(event) => setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ? <div className="flex items-center gap-4"><div className="relative h-28 w-40 shrink-0 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white"><Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-cover" /></div><div className="min-w-0"><p className="break-all text-sm font-semibold text-[#101828]">{previewImage.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换图片</button></div></div> : <div className="flex flex-col items-center py-4 text-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><ImagePlus className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放预览图片到这里,或选择图片</p><p className="mt-1 text-sm text-[#66758b]">支持 JPG、PNG、WebP,单张不超过 50MB</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择图片</button></div>}</div></section>
<section aria-labelledby="brand-knowledge-file-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>{selectedFile && <button type="button" onClick={() => setSelectedFile(null)} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除文件</button>}</div><div onDragEnter={handleDragEnter} onDragOver={(event) => 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]'}`}><input ref={fileInputRef} id="brand-knowledge-file" aria-label="选择知识文件" type="file" accept={BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT} onChange={(event) => setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ? <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm"><FileText className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换文件</button></div> : <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><Upload className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p><p className="mt-1 max-w-xl text-sm text-[#66758b]">支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择文件</button></div>}</div></section>
</div>

View File

@ -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)
})
})

View File

@ -11,6 +11,11 @@ export type ValidatedBrandImage = {
height: number
}
export type BrandImageValidationOptions = {
maxBytes?: number
maxBytesLabel?: string
}
const FORMAT_DETAILS: Record<string, Pick<ValidatedBrandImage, 'extension' | 'contentType'>> = {
png: { extension: 'png', contentType: 'image/png' },
jpeg: { extension: 'jpg', contentType: 'image/jpeg' },
@ -24,9 +29,15 @@ const FORMAT_DETAILS: Record<string, Pick<ValidatedBrandImage, 'extension' | 'co
export async function validateBrandImage(
buffer: Buffer,
declaredContentType?: string | null,
options: BrandImageValidationOptions = {},
): Promise<ValidatedBrandImage> {
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, {

View File

@ -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(',')

View File

@ -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;

View File

@ -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\?\.\(\)/)
})