diff --git a/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts b/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts new file mode 100644 index 00000000..1f8902d2 --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts @@ -0,0 +1,52 @@ +import { readFile, stat } from 'fs/promises' +import path from 'path' +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' +import { authOptions } from '@/lib/auth' +import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access' +import { prisma } from '@/lib/prisma' +import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths' +import { isPathFreeOfSymbolicLinks, isPathInAllowedDirs } from '@/lib/path-security' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' } + +export async function GET(_request: Request, context: { params: { id: string } }) { + try { + const session = await getServerSession(authOptions) + await assertBrandCenterDashboardAccess(session) + const item = await prisma.brandKnowledge.findUnique({ + where: { id: context.params.id }, + select: { previewImagePath: true, previewImageMimeType: true, previewImageSize: true }, + }) + if (!item?.previewImagePath) return NextResponse.json({ success: false, error: '预览图不存在' }, { status: 404, headers: PRIVATE_HEADERS }) + + const uploadRoot = path.resolve(getUploadRootDir()) + const filePath = path.resolve(resolveStoredUploadPath(item.previewImagePath)) + if (!isPathInAllowedDirs(filePath, [uploadRoot]) || !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))) { + return NextResponse.json({ success: false, error: '预览图路径不可用' }, { status: 404, headers: PRIVATE_HEADERS }) + } + const fileStats = await stat(filePath) + if (!fileStats.isFile() || (item.previewImageSize != null && fileStats.size !== Number(item.previewImageSize))) { + return NextResponse.json({ success: false, error: '预览图不可用' }, { status: 404, headers: PRIVATE_HEADERS }) + } + const body = await readFile(filePath) + return new NextResponse(new Uint8Array(body), { + status: 200, + headers: { + ...PRIVATE_HEADERS, + 'Content-Type': item.previewImageMimeType || 'image/webp', + 'Content-Length': String(fileStats.size), + 'Content-Disposition': 'inline', + }, + }) + } catch (error) { + if (error instanceof BrandCenterDashboardAccessError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS }) + } + console.error('[department-dashboard] 品牌知识预览图读取失败:', error) + return NextResponse.json({ success: false, error: '品牌知识预览图读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS }) + } +} diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index 0210bcd8..52d4b808 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -10,15 +10,19 @@ 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 } 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 = 52 * 1024 * 1024 +const MAX_REQUEST_SIZE = 58 * 1024 * 1024 +const MAX_PREVIEW_IMAGE_SIZE = MAX_BRAND_IMAGE_BYTES const KNOWLEDGE_TYPES = new Set(['品牌规范', '产品知识', '内容规范', '流程文档']) -const ALLOWED_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.csv', '.md', '.txt']) +// The source file stays independent from Material/MaterialAsset rows. +const ALLOWED_EXTENSIONS: ReadonlySet = new Set(BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS) const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' } function accessErrorResponse(error: unknown) { @@ -37,9 +41,14 @@ function serializeKnowledge(item: { title: string knowledgeType: string brand: string + productCategoryName: string fileName: string mimeType: string fileSize: bigint + previewImageName: string | null + previewImagePath: string | null + previewImageMimeType: string | null + previewImageSize: bigint | null createdAt: Date uploader: { realName: string | null; username: string } }) { @@ -48,12 +57,15 @@ function serializeKnowledge(item: { title: item.title, knowledgeType: item.knowledgeType, brand: item.brand, + productCategoryName: item.productCategoryName, fileName: item.fileName, mimeType: item.mimeType, fileSize: Number(item.fileSize), createdAt: item.createdAt.toISOString(), uploadedBy: item.uploader.realName || item.uploader.username, downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`, + previewUrl: item.previewImagePath ? `/api/department-dashboard/brand-knowledge/${item.id}/preview` : null, + previewImageName: item.previewImageName, } } @@ -72,6 +84,7 @@ export async function GET(request: Request) { { title: { contains: query, mode: 'insensitive' } }, { fileName: { contains: query, mode: 'insensitive' } }, { brand: { contains: query, mode: 'insensitive' } }, + { productCategoryName: { contains: query, mode: 'insensitive' } }, ], } : {}), }, @@ -82,9 +95,14 @@ export async function GET(request: Request) { title: true, knowledgeType: true, brand: true, + productCategoryName: true, fileName: true, mimeType: true, fileSize: true, + previewImageName: true, + previewImagePath: true, + previewImageMimeType: true, + previewImageSize: true, createdAt: true, uploader: { select: { realName: true, username: true } }, }, @@ -100,7 +118,9 @@ export async function GET(request: Request) { export async function POST(request: Request) { let storedPath: string | null = null + let storedPreviewPath: string | null = null let temporaryPath: string | null = null + let temporaryPreviewPath: string | null = null let releaseRequestSlot: (() => void) | null = null try { const session = await getServerSession(authOptions) @@ -112,25 +132,54 @@ export async function POST(request: Request) { releaseRequestSlot = await boundedMultipartRequestLimiter.acquire() const formData = await request.formData() assertMultipartFiles(formData, { - fields: { file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE } }, - maxFiles: 1, - maxTotalBytes: MAX_FILE_SIZE, + fields: { + file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE }, + previewImage: { + maxCount: 1, + maxFileBytes: MAX_PREVIEW_IMAGE_SIZE, + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + }, + maxFiles: 2, + maxTotalBytes: MAX_FILE_SIZE + MAX_PREVIEW_IMAGE_SIZE, }) const file = formData.get('file') + const previewImage = formData.get('previewImage') if (!(file instanceof File)) { return NextResponse.json({ success: false, error: '请选择知识文件' }, { status: 400, headers: PRIVATE_HEADERS }) } + if (previewImage !== null && !(previewImage instanceof File)) { + 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) + 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 }) + 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 }) + } + try { + validatedPreview = await validateBrandImage(Buffer.from(await previewImage.arrayBuffer()), previewImage.type) + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : '预览图无效,请上传 PNG、JPEG 或 WebP 图片' }, + { status: 400, headers: PRIVATE_HEADERS }, + ) + } + previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${validatedPreview.extension}` + } + const safeOriginalName = normalizeSafeFileName(file.name) if (!safeOriginalName) return NextResponse.json({ success: false, error: '文件名不合法' }, { status: 400, headers: PRIVATE_HEADERS }) const extension = path.extname(safeOriginalName).toLowerCase() - if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '仅支持 PDF、Office、表格、Markdown 和文本文件' }, { status: 400, headers: PRIVATE_HEADERS }) + if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '文件格式不支持,请参考素材资产库支持的格式' }, { status: 400, headers: PRIVATE_HEADERS }) const uploadRoot = getUploadRootDir() const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge') @@ -145,15 +194,34 @@ export async function POST(request: Request) { temporaryPath = null storedPath = `uploads/brand-knowledge/${storageName}` + let previewStorageName: string | null = null + if (previewImage instanceof File && validatedPreview) { + previewStorageName = `${randomUUID()}.${validatedPreview.extension}` + const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName) + if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) { + throw new Error('品牌知识预览图路径不可用') + } + temporaryPreviewPath = `${previewDestination}.uploading` + await writeFile(temporaryPreviewPath, Buffer.from(await previewImage.arrayBuffer()), { flag: 'wx' }) + await rename(temporaryPreviewPath, previewDestination) + temporaryPreviewPath = null + storedPreviewPath = `uploads/brand-knowledge/${previewStorageName}` + } + const created = await prisma.brandKnowledge.create({ data: { title, knowledgeType, brand, + productCategoryName, fileName: safeOriginalName, filePath: storedPath, mimeType: file.type || 'application/octet-stream', fileSize: BigInt(file.size), + previewImageName, + previewImagePath: storedPreviewPath, + previewImageMimeType: validatedPreview?.contentType || null, + previewImageSize: previewImage instanceof File ? BigInt(previewImage.size) : null, uploadedBy: userId, }, select: { @@ -161,19 +229,28 @@ export async function POST(request: Request) { title: true, knowledgeType: true, brand: true, + productCategoryName: true, fileName: true, mimeType: true, fileSize: true, + previewImageName: true, + previewImagePath: true, + previewImageMimeType: true, + previewImageSize: true, createdAt: true, uploader: { select: { realName: true, username: true } }, }, }) storedPath = null + storedPreviewPath = null return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS }) } catch (error) { if (temporaryPath) { await unlink(temporaryPath).catch(() => undefined) } + if (temporaryPreviewPath) { + await unlink(temporaryPreviewPath).catch(() => undefined) + } if (storedPath) { try { const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, '')) @@ -182,6 +259,14 @@ export async function POST(request: Request) { // 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。 } } + if (storedPreviewPath) { + try { + const absolute = path.join(getUploadRootDir(), storedPreviewPath.replace(/^uploads\//, '')) + await unlink(absolute) + } catch { + // Keep the original error if cleanup of an orphan preview fails. + } + } const response = accessErrorResponse(error) if (response) return response const boundedFailure = getBoundedMultipartFailure(error) diff --git a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx index 21c2dbe6..f4f8c3da 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx @@ -7,7 +7,16 @@ jest.mock('next-auth/react', () => ({ })) describe('BrandKnowledgeDashboard', () => { - beforeEach(() => { global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) }) + beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test-preview') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }) + 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) + } + return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) + }) + }) afterEach(() => { jest.restoreAllMocks() }) it('shows the real upload entry and empty state', async () => { @@ -33,7 +42,15 @@ describe('BrandKnowledgeDashboard', () => { it('submits a selected knowledge file to the independent API', async () => { const fetchMock = global.fetch as jest.Mock - fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: [] }) } as Response).mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: {} }) } as Response) + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/product-categories') { + return Promise.resolve({ ok: true, json: async () => [{ id: 'category-1', name: '床类' }] } as Response) + } + if (init?.method === 'POST') { + return Promise.resolve({ ok: true, json: async () => ({ success: true, data: {} }) } as Response) + } + return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) + }) render() const uploadButton = screen.getByRole('button', { name: '上传知识' }) @@ -43,12 +60,23 @@ describe('BrandKnowledgeDashboard', () => { expect(screen.getByRole('button', { name: '提交知识' })).toBeDisabled() fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } }) + await waitFor(() => expect(screen.getByRole('option', { name: '床类' })).toBeInTheDocument()) + fireEvent.change(screen.getByLabelText(/品类/), { target: { value: '床类' } }) const knowledgeFile = new File(['品牌资料'], '品牌手册.pdf', { type: 'application/pdf' }) + const previewFile = new File(['fake image'], '预览图.png', { type: 'image/png' }) 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('button', { name: '提交知识' })).toBeEnabled() fireEvent.click(screen.getByRole('button', { name: '提交知识' })) - await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) }))) + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) }))) + const postCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST') + expect(postCall).toBeDefined() + const submittedFormData = postCall?.[1]?.body as FormData + expect(submittedFormData.get('productCategoryName')).toBe('床类') + expect(submittedFormData.get('file')).toBe(knowledgeFile) + expect(submittedFormData.get('previewImage')).toBe(previewFile) fireEvent.click(screen.getByRole('button', { name: '关闭上传知识窗口' })) expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument() diff --git a/components/department-dashboard/BrandKnowledgeDashboard.tsx b/components/department-dashboard/BrandKnowledgeDashboard.tsx index 807616a2..44060ff9 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.tsx @@ -1,12 +1,13 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import Image from 'next/image' import { BookOpen, ChevronDown, Download, FileText, Search, Upload } from 'lucide-react' import DepartmentShell from './DepartmentShell' import BrandKnowledgeUploadDialog from './BrandKnowledgeUploadDialog' const KNOWLEDGE_TYPES = ['全部', '品牌规范', '产品知识', '内容规范', '流程文档'] -type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; fileName: string; mimeType: string; fileSize: number; createdAt: string; uploadedBy: string; downloadUrl: string } +type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; productCategoryName: string; fileName: string; mimeType: string; fileSize: number; createdAt: string; uploadedBy: string; downloadUrl: string; previewUrl: string | null; previewImageName: string | null } function formatFileSize(size: number) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB` } function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date) } @@ -28,7 +29,7 @@ export default function BrandKnowledgeDashboard() { }, []) useEffect(() => { void loadKnowledge() }, [loadKnowledge]) - const visibleItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); return items.filter((item) => (selectedType === '全部' || item.knowledgeType === selectedType) && (!keyword || [item.title, item.fileName, item.brand, item.uploadedBy].some((value) => value.toLowerCase().includes(keyword)))) }, [items, searchKeyword, selectedType]) + const visibleItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); return items.filter((item) => (selectedType === '全部' || item.knowledgeType === selectedType) && (!keyword || [item.title, item.fileName, item.brand, item.productCategoryName, item.uploadedBy].some((value) => value.toLowerCase().includes(keyword)))) }, [items, searchKeyword, selectedType]) const emptyMessage = useMemo(() => { if (loadError) return loadError @@ -121,7 +122,7 @@ export default function BrandKnowledgeDashboard() { - {isLoading ?
正在读取知识内容…
: visibleItems.length > 0 ?
    {visibleItems.map((item) =>
  • {item.title}

    {item.fileName}

    {item.knowledgeType}{item.brand && item.brand !== '未关联品牌' && {item.brand}}{formatFileSize(item.fileSize)}上传人:{item.uploadedBy}{formatDate(item.createdAt)}
  • )}
:
+ {isLoading ?
正在读取知识内容…
: visibleItems.length > 0 ?
    {visibleItems.map((item) =>
  • {item.previewUrl ? {`预览图:${item.title}`} : }

    {item.title}

    {item.fileName}

    {item.knowledgeType}{item.brand && item.brand !== '未关联品牌' && {item.brand}}{item.productCategoryName && {item.productCategoryName}}{formatFileSize(item.fileSize)}上传人:{item.uploadedBy}{formatDate(item.createdAt)}
  • )}
: