fix: preprocess oversized knowledge previews in browser

This commit is contained in:
江林铖 2026-09-15 13:05:30 +08:00
parent 2db3cf962f
commit 2be20f84c3
13 changed files with 522 additions and 28 deletions

View File

@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'
import { rename, unlink, writeFile } from 'fs/promises'
import { prisma } from '@/lib/prisma'
import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview'
import { validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview'
const releaseRequestSlot = jest.fn()
@ -14,7 +15,10 @@ jest.mock('@/lib/brand-center-dashboard-access', () => ({
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
}))
jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { create: jest.fn() } } }))
jest.mock('@/lib/brand-knowledge-preview', () => ({ prepareBrandKnowledgePreview: jest.fn() }))
jest.mock('@/lib/brand-knowledge-preview', () => ({
prepareBrandKnowledgePreview: jest.fn(),
validateBrandKnowledgeUploadedPreviewSlices: jest.fn(),
}))
jest.mock('@/lib/upload-paths', () => ({ getUploadRootDir: () => '/uploads' }))
jest.mock('@/lib/path-security', () => ({
ensureSafeDirectoryWithinRoot: jest.fn(async () => '/uploads/brand-knowledge'),
@ -72,7 +76,47 @@ function uploadRequest() {
])
return {
headers: new Headers(),
formData: async () => ({ get: (name: string) => values.get(name) ?? null }),
formData: async () => ({
get: (name: string) => values.get(name) ?? null,
getAll: (name: string) => {
const value = values.get(name)
return value === undefined ? [] : Array.isArray(value) ? value : [value]
},
}),
} as unknown as Request
}
function slicedUploadRequest() {
const firstSlice = {
name: 'long-001.webp', size: 11, type: 'image/webp',
arrayBuffer: async () => Uint8Array.from(Buffer.from('first-slice')).buffer,
}
const secondSlice = {
name: 'long-002.webp', size: 12, type: 'image/webp',
arrayBuffer: async () => Uint8Array.from(Buffer.from('second-slice')).buffer,
}
const values = new Map<string, unknown>([
['title', '长图规范'],
['knowledgeType', '影像规范'],
['brand', '索菲亚'],
['productCategoryName', '衣柜'],
['file', mainFile],
['previewImageName', 'long.png'],
['previewImageSliceCount', '2'],
['previewImageSlices', [firstSlice, secondSlice]],
])
return {
headers: new Headers(),
formData: async () => ({
get: (name: string) => {
const value = values.get(name)
return Array.isArray(value) ? value[0] : value ?? null
},
getAll: (name: string) => {
const value = values.get(name)
return value === undefined ? [] : Array.isArray(value) ? value : [value]
},
}),
} as unknown as Request
}
@ -109,6 +153,13 @@ describe('brand knowledge upload route', () => {
source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 },
slices: [Buffer.from('first-slice'), Buffer.from('second-slice')],
})
;(validateBrandKnowledgeUploadedPreviewSlices as jest.Mock).mockResolvedValue({
slices: [
{ buffer: Buffer.from('first-slice'), width: 1600, height: 1500 },
{ buffer: Buffer.from('second-slice'), width: 1600, height: 800 },
],
totalBytes: 23,
})
;(prisma.brandKnowledge.create as jest.Mock).mockImplementation(async ({ data }: { data: Record<string, unknown> }) => createdRecord(data))
})
@ -143,4 +194,20 @@ describe('brand knowledge upload route', () => {
expect(errorSpy).toHaveBeenCalled()
expect(releaseRequestSlot).toHaveBeenCalledTimes(1)
})
test('accepts ordered browser slices without decoding the original long image on the server', async () => {
const response = await POST(slicedUploadRequest())
const call = (prisma.brandKnowledge.create as jest.Mock).mock.calls[0][0]
expect(response.status).toBe(201)
expect(prepareBrandKnowledgePreview).not.toHaveBeenCalled()
expect(validateBrandKnowledgeUploadedPreviewSlices).toHaveBeenCalledWith([
expect.objectContaining({ declaredContentType: 'image/webp' }),
expect.objectContaining({ declaredContentType: 'image/webp' }),
])
expect(call.data.previewImageName).toBe('long.png')
expect(call.data.previewImageSlices).toHaveLength(2)
expect(call.data.previewImageSlices[0]).toMatch(/-part-001\.webp$/)
expect(call.data.previewImageSlices[1]).toMatch(/-part-002\.webp$/)
})
})

View File

@ -10,12 +10,14 @@ 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 { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview'
import { prepareBrandKnowledgePreview, validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview'
import { isWebFileLike } from '@/lib/web-file'
import {
BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS,
BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
BRAND_KNOWLEDGE_MAX_REQUEST_BYTES,
BRAND_KNOWLEDGE_TYPE_OPTIONS,
} from '@/lib/brand-knowledge-file-types'
@ -160,18 +162,31 @@ export async function POST(request: Request) {
maxFileBytes: MAX_PREVIEW_IMAGE_SIZE,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
previewImageSlices: {
maxCount: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
maxFileBytes: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
maxTotalBytes: MAX_PREVIEW_IMAGE_SIZE,
allowedMimeTypes: ['image/webp'],
},
},
maxFiles: 2,
maxFiles: 1 + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
maxTotalBytes: MAX_FILE_SIZE + MAX_PREVIEW_IMAGE_SIZE,
})
const file = formData.get('file')
const previewImage = formData.get('previewImage')
const uploadedPreviewSlices = formData.getAll('previewImageSlices')
if (!isWebFileLike(file)) {
return NextResponse.json({ success: false, error: '请选择知识文件' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (previewImage !== null && !isWebFileLike(previewImage)) {
return NextResponse.json({ success: false, error: '预览图文件无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (isWebFileLike(previewImage) && uploadedPreviewSlices.length > 0) {
return NextResponse.json({ success: false, error: '预览图不能同时提交原图和切片' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (uploadedPreviewSlices.some((slice) => !isWebFileLike(slice))) {
return NextResponse.json({ success: false, error: '预览图切片文件无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
const title = normalizeText(formData.get('title'), 200)
const knowledgeType = normalizeText(formData.get('knowledgeType'), 30)
const brand = normalizeText(formData.get('brand'), 40)
@ -181,6 +196,7 @@ export async function POST(request: Request) {
if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS })
let preparedPreview: Awaited<ReturnType<typeof prepareBrandKnowledgePreview>> | null = null
let preparedUploadedSlices: Awaited<ReturnType<typeof validateBrandKnowledgeUploadedPreviewSlices>> | null = null
let previewImageName: string | null = null
if (isWebFileLike(previewImage)) {
if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) {
@ -195,6 +211,25 @@ export async function POST(request: Request) {
)
}
previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${preparedPreview.source.extension}`
} else if (uploadedPreviewSlices.length > 0) {
const declaredSliceCount = Number(normalizeText(formData.get('previewImageSliceCount'), 4))
if (!Number.isSafeInteger(declaredSliceCount) || declaredSliceCount !== uploadedPreviewSlices.length) {
return NextResponse.json({ success: false, error: '预览图切片数量不一致,请重新选择图片' }, { status: 400, headers: PRIVATE_HEADERS })
}
try {
preparedUploadedSlices = await validateBrandKnowledgeUploadedPreviewSlices(
await Promise.all(uploadedPreviewSlices.map(async (slice) => {
const fileSlice = slice as File
return { buffer: Buffer.from(await fileSlice.arrayBuffer()), declaredContentType: fileSlice.type }
})),
)
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : '预览图切片无效' },
{ status: 400, headers: PRIVATE_HEADERS },
)
}
previewImageName = normalizeSafeFileName(normalizeText(formData.get('previewImageName'), 255)) || 'preview.webp'
}
const safeOriginalName = normalizeSafeFileName(file.name)
@ -215,9 +250,12 @@ export async function POST(request: Request) {
temporaryPath = null
storedPath = `uploads/brand-knowledge/${storageName}`
if (isWebFileLike(previewImage) && preparedPreview) {
const previewSlices = preparedUploadedSlices
? preparedUploadedSlices.slices.map((slice) => slice.buffer)
: preparedPreview?.slices || []
if (previewSlices.length > 0) {
const previewBatchId = randomUUID()
for (let index = 0; index < preparedPreview.slices.length; index += 1) {
for (let index = 0; index < previewSlices.length; index += 1) {
const previewStorageName = `${previewBatchId}-part-${String(index + 1).padStart(3, '0')}.webp`
const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName)
if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) {
@ -225,7 +263,7 @@ export async function POST(request: Request) {
}
const temporaryPreviewPath = `${previewDestination}.uploading`
temporaryPreviewPaths.push(temporaryPreviewPath)
await writeFile(temporaryPreviewPath, preparedPreview.slices[index], { flag: 'wx' })
await writeFile(temporaryPreviewPath, previewSlices[index], { flag: 'wx' })
await rename(temporaryPreviewPath, previewDestination)
temporaryPreviewPaths.splice(temporaryPreviewPaths.indexOf(temporaryPreviewPath), 1)
storedPreviewPaths.push(`uploads/brand-knowledge/${previewStorageName}`)
@ -245,8 +283,8 @@ export async function POST(request: Request) {
previewImageName,
previewImagePath: storedPreviewPaths[0] || null,
previewImageSlices: [...storedPreviewPaths],
previewImageMimeType: preparedPreview ? 'image/webp' : null,
previewImageSize: preparedPreview ? BigInt(preparedPreview.slices[0].length) : null,
previewImageMimeType: previewSlices.length > 0 ? 'image/webp' : null,
previewImageSize: previewSlices.length > 0 ? BigInt(previewSlices[0].length) : null,
uploadedBy: userId,
},
select: {

View File

@ -2,6 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import BrandKnowledgeDashboard from './BrandKnowledgeDashboard'
import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types'
const mockPrepareBrandKnowledgePreviewInBrowser = jest.fn()
jest.mock('@/lib/brand-knowledge-image-browser', () => ({
prepareBrandKnowledgePreviewInBrowser: (...args: unknown[]) => mockPrepareBrandKnowledgePreviewInBrowser(...args),
canUseBrandKnowledgeLegacyServerPreview: jest.fn(async () => false),
}))
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
signOut: jest.fn(),
@ -12,6 +19,30 @@ describe('BrandKnowledgeDashboard', () => {
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test-preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() })
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } })
mockPrepareBrandKnowledgePreviewInBrowser.mockResolvedValue([
{
file: new File(['slice one'], '预览图-001.webp', { type: 'image/webp' }),
mimeType: 'image/webp',
width: 1600,
height: 1500,
kind: 'slice',
sourceName: '预览图.png',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 2,
},
{
file: new File(['slice two'], '预览图-002.webp', { type: 'image/webp' }),
mimeType: 'image/webp',
width: 1600,
height: 900,
kind: 'slice',
sourceName: '预览图.png',
sourceIndex: 0,
sliceIndex: 1,
sliceCount: 2,
},
])
global.fetch = jest.fn().mockImplementation((input: RequestInfo | URL) => {
if (String(input) === '/api/product-categories') {
return Promise.resolve({ ok: true, json: async () => [{ id: 'category-1', name: '床类' }] } as Response)
@ -186,8 +217,9 @@ describe('BrandKnowledgeDashboard', () => {
fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [knowledgeFile] } })
fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
expect(screen.getByText('品牌手册.pdf')).toBeInTheDocument()
expect(screen.getByText('预览图.png')).toBeInTheDocument()
expect(screen.getByRole('img', { name: '预览图:预览图.png' })).toHaveClass('object-contain', 'object-center')
expect(await screen.findByText('预览图.png')).toBeInTheDocument()
expect(await screen.findByRole('img', { name: '预览图:预览图.png' })).toHaveClass('object-contain', 'object-center')
expect(screen.getByText(/已切为 2 片/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交知识' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) })))
@ -196,7 +228,13 @@ describe('BrandKnowledgeDashboard', () => {
const submittedFormData = postCall?.[1]?.body as FormData
expect(submittedFormData.get('productCategoryName')).toBe('床类')
expect(submittedFormData.get('file')).toBe(knowledgeFile)
expect(submittedFormData.get('previewImage')).toBe(previewFile)
expect(submittedFormData.get('previewImage')).toBeNull()
expect(submittedFormData.get('previewImageName')).toBe('预览图.png')
expect(submittedFormData.get('previewImageSliceCount')).toBe('2')
expect(submittedFormData.getAll('previewImageSlices').map((entry) => (entry as File).name)).toEqual([
'预览图-001.webp',
'预览图-002.webp',
])
fireEvent.click(screen.getByRole('button', { name: '关闭上传知识窗口' }))
expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()

View File

@ -11,6 +11,12 @@ import {
BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT,
BRAND_KNOWLEDGE_TYPE_OPTIONS,
} from '@/lib/brand-knowledge-file-types'
import {
prepareBrandKnowledgePreviewInBrowser,
canUseBrandKnowledgeLegacyServerPreview,
type BrandKnowledgeBrowserPreviewSlice,
} from '@/lib/brand-knowledge-image-browser'
import { isPerformanceSurveyBrowserCapabilityError } from '@/lib/performance-survey-image-error'
const BRAND_OPTIONS = ['未关联品牌', '亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美']
const MAX_MAIN_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES
@ -43,7 +49,10 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
const [productCategories, setProductCategories] = useState<ProductCategory[]>([])
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewSlices, setPreviewSlices] = useState<BrandKnowledgeBrowserPreviewSlice[]>([])
const [previewUrl, setPreviewUrl] = useState('')
const [previewProgress, setPreviewProgress] = useState('')
const [previewUsesServerFallback, setPreviewUsesServerFallback] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isPreviewDragging, setIsPreviewDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
@ -54,6 +63,8 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
const previewInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const previewDragDepthRef = useRef(0)
const previewProcessingAbortRef = useRef<AbortController | null>(null)
const previewProcessingVersionRef = useRef(0)
useEffect(() => {
const previousOverflow = document.body.style.overflow
@ -80,6 +91,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
}
document.addEventListener('keydown', handleKeyDown)
return () => {
previewProcessingAbortRef.current?.abort()
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
returnFocusElement?.focus()
@ -115,7 +127,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
setSelectedFile(file)
}
const setPreview = (file: File | undefined) => {
const setPreview = async (file: File | undefined) => {
if (!file) return
const extension = extensionOf(file)
if (!PREVIEW_EXTENSIONS.has(extension) || (file.type && !['image/jpeg', 'image/png', 'image/webp'].includes(file.type))) {
@ -126,16 +138,55 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
setErrorMessage('预览图需为 1B 至 50MB 之间的有效图片')
return
}
if (previewUrl) URL.revokeObjectURL(previewUrl)
previewProcessingAbortRef.current?.abort()
previewProcessingVersionRef.current += 1
const processingVersion = previewProcessingVersionRef.current
const controller = new AbortController()
previewProcessingAbortRef.current = controller
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setPreviewSlices([])
setPreviewUrl('')
setPreviewUsesServerFallback(false)
setPreviewProgress('正在浏览器后台处理长图并生成预览切片…')
setErrorMessage('')
try {
const slices = await prepareBrandKnowledgePreviewInBrowser(
file,
(label) => {
if (previewProcessingVersionRef.current === processingVersion) setPreviewProgress(label)
},
{ signal: controller.signal },
)
if (previewProcessingVersionRef.current !== processingVersion) return
setPreviewSlices(slices)
setPreviewUrl(URL.createObjectURL(slices[0].file))
setPreviewProgress(`已生成 ${slices.length} 个有序预览切片,上传后将连续展示`)
} catch (error) {
if (previewProcessingVersionRef.current !== processingVersion || (error instanceof Error && error.name === 'AbortError')) return
if (isPerformanceSurveyBrowserCapabilityError(error)
&& await canUseBrandKnowledgeLegacyServerPreview(file)) {
if (previewProcessingVersionRef.current !== processingVersion) return
setPreviewUsesServerFallback(true)
setPreviewProgress('当前浏览器将由服务器生成普通图片预览')
return
}
setPreviewProgress('')
setErrorMessage(error instanceof Error ? error.message : '预览图处理失败,请重新选择图片')
} finally {
if (previewProcessingVersionRef.current === processingVersion) previewProcessingAbortRef.current = null
}
}
const removePreview = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
previewProcessingVersionRef.current += 1
previewProcessingAbortRef.current?.abort()
previewProcessingAbortRef.current = null
setPreviewImage(null)
setPreviewSlices([])
setPreviewUrl('')
setPreviewProgress('')
setPreviewUsesServerFallback(false)
setErrorMessage('')
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current += 1; setIsDragging(true) }
@ -144,7 +195,8 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
const handlePreviewDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); previewDragDepthRef.current -= 1; if (previewDragDepthRef.current <= 0) { previewDragDepthRef.current = 0; setIsPreviewDragging(false) } }
const handleSubmit = async () => {
if (isUploading || !selectedFile || !title.trim()) return
if (isUploading || !selectedFile || !title.trim()
|| (previewImage && previewSlices.length === 0 && !previewUsesServerFallback)) return
setIsUploading(true)
setErrorMessage('')
try {
@ -154,7 +206,15 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
formData.set('brand', brand)
formData.set('productCategoryName', productCategoryName)
formData.set('file', selectedFile)
if (previewImage) formData.set('previewImage', previewImage)
if (previewImage) {
if (previewUsesServerFallback) {
formData.set('previewImage', previewImage)
} else {
formData.set('previewImageName', previewImage.name)
formData.set('previewImageSliceCount', String(previewSlices.length))
previewSlices.forEach((slice) => formData.append('previewImageSlices', slice.file))
}
}
const response = await fetch('/api/department-dashboard/brand-knowledge', { method: 'POST', body: formData })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识上传失败,请稍后重试')
@ -173,17 +233,17 @@ 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},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB,长图会自动切片并连续展示。</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 || previewProgress || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && !previewProgress && <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-contain object-center" /></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-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); void 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) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ? <div className="flex items-center gap-4"><div className="relative flex h-28 w-40 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#e2e8f0] bg-white">{previewUrl ? <Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-contain object-center" /> : <ImagePlus className="h-6 w-6 text-[#8a99ad]" aria-hidden="true" />}</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)} · {previewSlices.length > 0 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}</p><button type="button" onClick={() => previewInputRef.current?.click()} disabled={Boolean(previewProgress && previewSlices.length === 0 && !previewUsesServerFallback)} 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] disabled:cursor-not-allowed disabled:opacity-55 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>
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8"><button type="button" onClick={onClose} className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 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">取消</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim()} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Upload className="h-4 w-4" aria-hidden="true" />{isUploading ? '上传中...' : '提交知识'}</button></footer>
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8"><button type="button" onClick={onClose} className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 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">取消</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim() || Boolean(previewImage && previewSlices.length === 0 && !previewUsesServerFallback)} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Upload className="h-4 w-4" aria-hidden="true" />{isUploading ? '上传中...' : previewImage && previewSlices.length === 0 && !previewUsesServerFallback ? '正在处理预览图' : '提交知识'}</button></footer>
</div>
</div>
)

View File

@ -0,0 +1,72 @@
import {
canUseBrandKnowledgeLegacyServerPreview,
prepareBrandKnowledgePreviewInBrowser,
} from '@/lib/brand-knowledge-image-browser'
const mockPreparePerformanceSurveyImagesInBrowser = jest.fn()
jest.mock('@/lib/performance-survey-image-browser', () => ({
preparePerformanceSurveyImagesInBrowser: (...args: unknown[]) => (
mockPreparePerformanceSurveyImagesInBrowser(...args)
),
}))
describe('brand knowledge preview browser processing', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPreparePerformanceSurveyImagesInBrowser.mockResolvedValue([
{ file: new File(['one'], 'long-001.webp', { type: 'image/webp' }), kind: 'slice' },
{ file: new File(['two'], 'long-002.webp', { type: 'image/webp' }), kind: 'slice' },
])
})
test('uses the dedicated 120MP brand profile and returns ordered slices', async () => {
const controller = new AbortController()
const source = new File(['image'], 'long.jpg', { type: 'image/jpeg' })
const result = await prepareBrandKnowledgePreviewInBrowser(source, undefined, {
signal: controller.signal,
timeoutMs: 12_345,
})
expect(result.map((item) => item.file.name)).toEqual(['long-001.webp', 'long-002.webp'])
expect(mockPreparePerformanceSurveyImagesInBrowser).toHaveBeenCalledWith(
[source],
undefined,
{
profile: 'brand_knowledge_preview',
signal: controller.signal,
timeoutMs: 12_345,
},
)
})
test('allows server fallback only when the image header is within 60 million pixels', async () => {
function pngFile(width: number, height: number) {
const bytes = new Uint8Array(24)
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const view = new DataView(bytes.buffer)
view.setUint32(16, width)
view.setUint32(20, height)
return {
name: 'preview.png',
size: bytes.byteLength,
slice: () => ({ arrayBuffer: async () => bytes.buffer }),
} as unknown as File
}
await expect(canUseBrandKnowledgeLegacyServerPreview(pngFile(10_000, 6_000))).resolves.toBe(true)
await expect(canUseBrandKnowledgeLegacyServerPreview(pngFile(10_000, 6_001))).resolves.toBe(false)
})
test('rejects browser output that exceeds the derived preview byte limits', async () => {
mockPreparePerformanceSurveyImagesInBrowser.mockResolvedValue([{
file: { name: 'huge.webp', size: 10 * 1024 * 1024 + 1 },
kind: 'slice',
}])
await expect(prepareBrandKnowledgePreviewInBrowser(
new File(['image'], 'long.jpg', { type: 'image/jpeg' }),
)).rejects.toThrow('单个切片过大')
})
})

View File

@ -4,6 +4,7 @@ import sharp from 'sharp'
import {
assertBrandKnowledgePreviewMetadata,
prepareBrandKnowledgePreview,
validateBrandKnowledgeUploadedPreviewSlices,
} from '@/lib/brand-knowledge-preview'
describe('brand knowledge long preview processing', () => {
@ -45,4 +46,40 @@ describe('brand knowledge long preview processing', () => {
format: 'webp', width: 800, height: 90_001, pages: 1,
}, 'image/webp')).toThrow('尺寸过大')
})
test('validates ordered browser WebP slices before storage', async () => {
const first = await sharp({
create: { width: 1_600, height: 1_500, channels: 3, background: '#fff' },
}).webp().toBuffer()
const last = await sharp({
create: { width: 1_600, height: 800, channels: 3, background: '#fff' },
}).webp().toBuffer()
const result = await validateBrandKnowledgeUploadedPreviewSlices([
{ buffer: first, declaredContentType: 'image/webp' },
{ buffer: last, declaredContentType: 'image/webp' },
])
expect(result.slices.map((slice) => [slice.width, slice.height])).toEqual([
[1_600, 1_500],
[1_600, 800],
])
})
test('rejects forged or geometrically inconsistent browser slices', async () => {
await expect(validateBrandKnowledgeUploadedPreviewSlices([
{ buffer: Buffer.from('not a webp'), declaredContentType: 'image/webp' },
])).rejects.toThrow('无法解码')
const shortFirst = await sharp({
create: { width: 1_600, height: 1_000, channels: 3, background: '#fff' },
}).webp().toBuffer()
const last = await sharp({
create: { width: 1_600, height: 800, channels: 3, background: '#fff' },
}).webp().toBuffer()
await expect(validateBrandKnowledgeUploadedPreviewSlices([
{ buffer: shortFirst, declaredContentType: 'image/webp' },
{ buffer: last, declaredContentType: 'image/webp' },
])).rejects.toThrow('高度应为 1500px')
})
})

View File

@ -1,5 +1,7 @@
import {
assertPerformanceSurveyBrowserSourceImageDimensions,
BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT,
BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS,
PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS,
readPerformanceSurveySourceImageDimensions,
TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS,
@ -63,4 +65,25 @@ describe('performance survey browser source image guard', () => {
{ maxPixels: TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS },
)).toThrow('像素总量超过 60,000,000')
})
test('品牌知识专用门禁允许 6000 万以上长图但保留 1.2 亿和高度上限', () => {
expect(() => assertPerformanceSurveyBrowserSourceImageDimensions(
{ width: 1_600, height: 50_000 },
'brand-long.png',
{
maxPixels: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS,
maxHeight: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT,
},
)).not.toThrow()
expect(() => assertPerformanceSurveyBrowserSourceImageDimensions(
{ width: 2_000, height: 60_001 },
'brand-too-many-pixels.png',
{ maxPixels: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS },
)).toThrow('像素总量超过 120,000,000')
expect(() => assertPerformanceSurveyBrowserSourceImageDimensions(
{ width: 1, height: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT + 1 },
'brand-too-tall.png',
{ maxHeight: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT },
)).toThrow('高度超过 90000px')
})
})

View File

@ -15,6 +15,8 @@ export const BRAND_KNOWLEDGE_MAIN_FILE_LABEL =
export const BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES = 1024 * 1024 * 1024
export const BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES = 50 * 1024 * 1024
export const BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES = 10 * 1024 * 1024
export const BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT = 60
// Includes multipart framing headroom for a 1 GiB main file and 50 MiB preview.
export const BRAND_KNOWLEDGE_MAX_REQUEST_BYTES = 1100 * 1024 * 1024

View File

@ -0,0 +1,66 @@
import {
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
} from '@/lib/brand-knowledge-file-types'
import {
preparePerformanceSurveyImagesInBrowser,
type PerformanceSurveyBrowserProcessedImage,
} from '@/lib/performance-survey-image-browser'
import {
BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS,
PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS,
readPerformanceSurveySourceImageDimensions,
} from '@/lib/performance-survey-image-source-guard'
export type BrandKnowledgeBrowserPreviewSlice = PerformanceSurveyBrowserProcessedImage & {
kind: 'slice'
}
export async function prepareBrandKnowledgePreviewInBrowser(
file: File,
onProgress?: (label: string) => void,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<BrandKnowledgeBrowserPreviewSlice[]> {
const processed = await preparePerformanceSurveyImagesInBrowser(
[file],
onProgress,
{
profile: 'brand_knowledge_preview',
signal: options.signal,
timeoutMs: options.timeoutMs,
},
)
const slices = processed.filter(
(item): item is BrandKnowledgeBrowserPreviewSlice => item.kind === 'slice',
)
if (slices.length === 0) throw new Error('浏览器未生成品牌知识预览切片')
if (slices.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT) {
throw new Error(`预览图处理后超过 ${BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT} 个切片`)
}
if (slices.some((slice) => slice.file.size <= 0 || slice.file.size > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES)) {
throw new Error('预览图处理后的单个切片过大,请先压缩图片后重试')
}
const totalBytes = slices.reduce((sum, slice) => sum + slice.file.size, 0)
if (totalBytes > BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES) {
throw new Error('预览图处理后的切片总大小超过 50MB,请先压缩图片后重试')
}
return slices
}
export async function canUseBrandKnowledgeLegacyServerPreview(file: File) {
let bytes = new Uint8Array(
await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes).arrayBuffer(),
)
let dimensions = readPerformanceSurveySourceImageDimensions(bytes)
if (!dimensions && file.size > bytes.byteLength) {
bytes = new Uint8Array(
await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes).arrayBuffer(),
)
dimensions = readPerformanceSurveySourceImageDimensions(bytes)
}
if (!dimensions) return false
return dimensions.width <= PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth
&& dimensions.height <= 90_000
&& dimensions.width * dimensions.height <= BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS
}

View File

@ -1,6 +1,12 @@
import sharp from 'sharp'
import { sliceImage } from '@/lib/image-processor'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import {
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
} from '@/lib/brand-knowledge-file-types'
configureSharpRuntime()
@ -85,3 +91,65 @@ export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredConte
if (slices.length === 0) throw new Error('预览图处理后没有可用片段')
return { source, slices }
}
export type BrandKnowledgeUploadedPreviewSlice = {
buffer: Buffer
declaredContentType?: string | null
}
export async function validateBrandKnowledgeUploadedPreviewSlices(
uploadedSlices: BrandKnowledgeUploadedPreviewSlice[],
) {
if (uploadedSlices.length < 1 || uploadedSlices.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT) {
throw new Error(`预览图切片数量需在 1 至 ${BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT} 片之间`)
}
const totalBytes = uploadedSlices.reduce((sum, slice) => sum + slice.buffer.length, 0)
if (totalBytes > BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES) {
throw new Error('预览图切片总大小不能超过 50MB')
}
return withImageProcessingSlot(async () => {
const slices: Array<{ buffer: Buffer; width: number; height: number }> = []
let expectedWidth = 0
for (let index = 0; index < uploadedSlices.length; index += 1) {
const uploaded = uploadedSlices[index]
if (uploaded.buffer.length < 1 || uploaded.buffer.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES) {
throw new Error(`预览图第 ${index + 1} 片大小无效`)
}
if (normalizeContentType(uploaded.declaredContentType) !== 'image/webp') {
throw new Error(`预览图第 ${index + 1} 片只支持 WebP 格式`)
}
let metadata: sharp.Metadata
try {
metadata = await sharp(uploaded.buffer, {
animated: true,
failOn: 'error',
limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
sequentialRead: true,
}).metadata()
} catch {
throw new Error(`预览图第 ${index + 1} 片无法解码`)
}
const width = metadata.width || 0
const height = metadata.height || 0
if (metadata.format !== 'webp' || Number(metadata.pages || 1) > 1) {
throw new Error(`预览图第 ${index + 1} 片不是有效的静态 WebP 图片`)
}
if (width < 1 || width > BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH
|| height < 1 || height > BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
throw new Error(`预览图第 ${index + 1} 片尺寸无效`)
}
if (index === 0) expectedWidth = width
if (width !== expectedWidth) throw new Error('预览图切片宽度不一致')
if (index < uploadedSlices.length - 1 && height !== BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
throw new Error(`预览图第 ${index + 1} 片高度应为 ${BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT}px`)
}
slices.push({ buffer: uploaded.buffer, width, height })
}
return { slices, totalBytes }
})
}

View File

@ -43,7 +43,7 @@ const DESKTOP_BROWSER_REQUIRED_MESSAGE = '图片需要在桌面版 Chrome 或 Ed
export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000
export type PerformanceSurveyBrowserImageOptions = {
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail'
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail' | 'brand_knowledge_preview'
signal?: AbortSignal
timeoutMs?: number
}

View File

@ -20,6 +20,9 @@ export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = {
// 任务详情在 Edge 预处理失败时会交给服务器 Sharp 兜底;两端必须使用同一
// 像素门禁,否则 60M~120M 的图片会在上传完成后才后台失败。
export const TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS = 60_000_000
export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS = 120_000_000
export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT = 90_000
export const BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS = 60_000_000
export type PerformanceSurveySourceImageDimensions = {
width: number
@ -122,7 +125,7 @@ export function readPerformanceSurveySourceImageDimensions(bytes: Uint8Array) {
export function assertPerformanceSurveyBrowserSourceImageDimensions(
dimensions: PerformanceSurveySourceImageDimensions | null,
sourceName: string,
options: { maxPixels?: number } = {},
options: { maxPixels?: number; maxHeight?: number } = {},
) {
if (!dimensions || !Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height)
|| dimensions.width <= 0 || dimensions.height <= 0) {
@ -137,6 +140,12 @@ export function assertPerformanceSurveyBrowserSourceImageDimensions(
`图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth}px`,
)
}
if (options.maxHeight && dimensions.height > options.maxHeight) {
throw new PerformanceSurveySourceImageError(
'SOURCE_DIMENSIONS_INVALID',
`图片「${sourceName}」高度超过 ${options.maxHeight}px`,
)
}
const maxPixels = options.maxPixels || PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
if (dimensions.width * dimensions.height > maxPixels) {
throw new PerformanceSurveySourceImageError(

View File

@ -5,6 +5,8 @@ import {
PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS,
readPerformanceSurveySourceImageDimensions,
TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS,
BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT,
BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS,
} from './performance-survey-image-source-guard'
import { PerformanceSurveyBrowserImageError } from './performance-survey-image-error'
@ -13,7 +15,7 @@ export {}
type WorkerRequest = {
id: string
files: File[]
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail'
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail' | 'brand_knowledge_preview'
}
type WorkerProcessedImage = {
@ -43,6 +45,7 @@ let previewHeight = PREVIEW_HEIGHT
let previewQuality = PREVIEW_QUALITY
let previewFit: 'cover' | 'inside' = 'cover'
let sourceMaxPixels: number = PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
let sourceMaxHeight: number | undefined
const workerScope = self as unknown as DedicatedWorkerGlobalScope
function removeFileExtension(fileName: string) {
@ -87,7 +90,10 @@ async function decodeImage(file: File) {
// JPEG 的 EXIF/ICC 段可能超过 256KB;头部暂时读不到 SOF 时交给浏览器实际解码,
// 再用解码后的尺寸做同一套限制,不能把合法图片误判成“不支持”。
if (headerDimensions) {
assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name, { maxPixels: sourceMaxPixels })
assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name, {
maxPixels: sourceMaxPixels,
maxHeight: sourceMaxHeight,
})
}
let bitmap: ImageBitmap
try {
@ -108,7 +114,7 @@ async function decodeImage(file: File) {
assertPerformanceSurveyBrowserSourceImageDimensions(
{ width: bitmap.width, height: bitmap.height },
file.name,
{ maxPixels: sourceMaxPixels },
{ maxPixels: sourceMaxPixels, maxHeight: sourceMaxHeight },
)
return bitmap
} catch (error) {
@ -376,7 +382,12 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const isTaskDetailThumbnail = profile === 'task_detail_thumbnail'
sourceMaxPixels = isTaskDetail
? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS
: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
: profile === 'brand_knowledge_preview'
? BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS
: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
sourceMaxHeight = profile === 'brand_knowledge_preview'
? BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT
: undefined
previewWidth = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 800 : PREVIEW_WIDTH
previewHeight = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 1_200 : PREVIEW_HEIGHT
previewQuality = isTaskDetailThumbnail ? 0.68 : isTaskDetail ? 0.74 : PREVIEW_QUALITY
@ -390,7 +401,10 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const images = files.length === 1
? profile === 'task_detail_preview' || profile === 'task_detail_thumbnail'
? await processSinglePreview(files[0], profile === 'task_detail_thumbnail' ? 'thumbnail' : 'preview')
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
: await processSingleOriginal(
files[0],
profile !== 'task_detail_detail' && profile !== 'brand_knowledge_preview',
)
: await processOrderedSlices(files)
workerScope.postMessage({ id, ok: true, images })
} catch (error) {