diff --git a/Dockerfile b/Dockerfile index 1240880..e28a1da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,7 +96,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql" && \ test -f "/app/prisma/migrations/20260909_expand_brand_knowledge_upload/migration.sql" && \ test -f "/app/prisma/migrations/20260914_add_brand_knowledge_engagement/migration.sql" && \ - test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" + test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" && \ + test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -226,6 +227,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_ COPY --from=builder --chown=root:root /app/prisma/migrations/20260909_expand_brand_knowledge_upload ./prisma/migrations/20260909_expand_brand_knowledge_upload COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_add_brand_knowledge_engagement ./prisma/migrations/20260914_add_brand_knowledge_engagement COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_download_count ./prisma/migrations/20260914_expand_brand_knowledge_download_count +COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices ./prisma/migrations/20260914_expand_brand_knowledge_preview_slices COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/department-dashboard/brand-knowledge/[id]/preview/route.test.ts b/app/api/department-dashboard/brand-knowledge/[id]/preview/route.test.ts new file mode 100644 index 0000000..54f115e --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/[id]/preview/route.test.ts @@ -0,0 +1,109 @@ +import { getServerSession } from 'next-auth' +import { readFile, stat } from 'fs/promises' +import { prisma } from '@/lib/prisma' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/brand-center-dashboard-access', () => ({ + BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error { + constructor(message: string, readonly status = 403) { super(message) } + }, + assertBrandCenterDashboardAccess: jest.fn(async () => undefined), +})) +jest.mock('@/lib/prisma', () => ({ + prisma: { brandKnowledge: { findUnique: jest.fn() } }, +})) +jest.mock('@/lib/upload-paths', () => ({ + getUploadRootDir: () => '/uploads', + resolveStoredUploadPath: (storedPath: string) => `/${storedPath}`, +})) +jest.mock('@/lib/path-security', () => ({ + isPathFreeOfSymbolicLinks: jest.fn(async () => true), + isPathInAllowedDirs: jest.fn(() => true), +})) +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), + stat: jest.fn(), +})) + +const { TextDecoder, TextEncoder } = require('util') as typeof import('util') +const { clearImmediate, setImmediate } = require('timers') as typeof import('timers') +Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate }) +const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis +Object.assign(globalThis, { + Headers: edgePrimitives.Headers, + Request: edgePrimitives.Request, + Response: edgePrimitives.Response, +}) + +let GET: typeof import('./route').GET + +describe('brand knowledge preview route', () => { + beforeAll(async () => { ({ GET } = await import('./route')) }) + + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) + ;(prisma.brandKnowledge.findUnique as jest.Mock).mockResolvedValue({ + previewImagePath: 'uploads/brand-knowledge/part-001.webp', + previewImageSlices: [ + 'uploads/brand-knowledge/part-001.webp', + 'uploads/brand-knowledge/part-002.webp', + ], + previewImageMimeType: 'image/webp', + previewImageSize: BigInt(4), + }) + ;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 8 }) + ;(readFile as jest.Mock).mockResolvedValue(Buffer.from('slice-02')) + }) + + test('serves the requested ordered WebP slice without applying the legacy size check', async () => { + const response = await GET( + new Request('http://localhost/preview?part=1'), + { params: { id: 'knowledge-1' } }, + ) + + expect(response.status).toBe(200) + expect(readFile).toHaveBeenCalledWith('/uploads/brand-knowledge/part-002.webp') + expect(response.headers.get('content-type')).toBe('image/webp') + await expect(response.text()).resolves.toBe('slice-02') + }) + + test('verifies the stored size for the first generated slice', async () => { + ;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 8 }) + + const response = await GET( + new Request('http://localhost/preview?part=0'), + { params: { id: 'knowledge-1' } }, + ) + + expect(response.status).toBe(404) + expect(readFile).not.toHaveBeenCalled() + }) + + test('rejects invalid and out-of-range slice indexes', async () => { + const invalid = await GET(new Request('http://localhost/preview?part=1.5'), { params: { id: 'knowledge-1' } }) + const missing = await GET(new Request('http://localhost/preview?part=2'), { params: { id: 'knowledge-1' } }) + + expect(invalid.status).toBe(400) + expect(missing.status).toBe(404) + expect(readFile).not.toHaveBeenCalled() + }) + + test('falls back to the legacy single preview and still verifies its stored size', async () => { + ;(prisma.brandKnowledge.findUnique as jest.Mock).mockResolvedValue({ + previewImagePath: 'uploads/brand-knowledge/legacy.png', + previewImageSlices: [], + previewImageMimeType: 'image/png', + previewImageSize: BigInt(4), + }) + ;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 4 }) + ;(readFile as jest.Mock).mockResolvedValue(Buffer.from('test')) + + const response = await GET(new Request('http://localhost/preview'), { params: { id: 'knowledge-1' } }) + + expect(response.status).toBe(200) + expect(readFile).toHaveBeenCalledWith('/uploads/brand-knowledge/legacy.png') + expect(response.headers.get('content-type')).toBe('image/png') + }) +}) diff --git a/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts b/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts index 1f8902d..b3b2f7b 100644 --- a/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts +++ b/app/api/department-dashboard/brand-knowledge/[id]/preview/route.ts @@ -13,23 +13,36 @@ 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 } }) { +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 }, + select: { previewImagePath: true, previewImageSlices: true, previewImageMimeType: true, previewImageSize: true }, }) if (!item?.previewImagePath) return NextResponse.json({ success: false, error: '预览图不存在' }, { status: 404, headers: PRIVATE_HEADERS }) + const rawPart = new URL(request.url).searchParams.get('part') + const part = rawPart === null || rawPart === '' ? 0 : Number(rawPart) + if (!Number.isSafeInteger(part) || part < 0) { + return NextResponse.json({ success: false, error: '预览图片段编号无效' }, { status: 400, headers: PRIVATE_HEADERS }) + } + const slicePaths = item.previewImageSlices.length > 0 ? item.previewImageSlices : [item.previewImagePath] + const selectedPath = slicePaths[part] + if (!selectedPath) return NextResponse.json({ success: false, error: '预览图片段不存在' }, { status: 404, headers: PRIVATE_HEADERS }) + const uploadRoot = path.resolve(getUploadRootDir()) - const filePath = path.resolve(resolveStoredUploadPath(item.previewImagePath)) + const filePath = path.resolve(resolveStoredUploadPath(selectedPath)) 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))) { + const isLegacySinglePreview = item.previewImageSlices.length === 0 + const expectedSize = isLegacySinglePreview || part === 0 + ? item.previewImageSize + : null + if (!fileStats.isFile() || (expectedSize != null && fileStats.size !== Number(expectedSize))) { return NextResponse.json({ success: false, error: '预览图不可用' }, { status: 404, headers: PRIVATE_HEADERS }) } const body = await readFile(filePath) @@ -37,7 +50,7 @@ export async function GET(_request: Request, context: { params: { id: string } } status: 200, headers: { ...PRIVATE_HEADERS, - 'Content-Type': item.previewImageMimeType || 'image/webp', + 'Content-Type': isLegacySinglePreview ? item.previewImageMimeType || 'image/webp' : 'image/webp', 'Content-Length': String(fileStats.size), 'Content-Disposition': 'inline', }, diff --git a/app/api/department-dashboard/brand-knowledge/[id]/route.ts b/app/api/department-dashboard/brand-knowledge/[id]/route.ts index 0a24d49..b654587 100644 --- a/app/api/department-dashboard/brand-knowledge/[id]/route.ts +++ b/app/api/department-dashboard/brand-knowledge/[id]/route.ts @@ -31,6 +31,7 @@ export async function GET(_request: Request, { params }: { params: { id: string downloadCount: true, previewImageName: true, previewImagePath: true, + previewImageSlices: true, createdAt: true, uploader: { select: { realName: true, username: true } }, likes: { where: { userId }, select: { id: true } }, @@ -59,6 +60,11 @@ export async function GET(_request: Request, { params }: { params: { id: string 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, + previewUrls: item.previewImageSlices.length > 0 + ? item.previewImageSlices.map((_, index) => `/api/department-dashboard/brand-knowledge/${item.id}/preview?part=${index}`) + : item.previewImagePath + ? [`/api/department-dashboard/brand-knowledge/${item.id}/preview`] + : [], previewImageName: item.previewImageName, likeCount: item._count.likes, isLiked: item.likes.length > 0, diff --git a/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts b/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts index 25ff4eb..c8c80b3 100644 --- a/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts +++ b/app/api/department-dashboard/brand-knowledge/engagement-routes.test.ts @@ -107,6 +107,10 @@ describe('brand knowledge engagement routes', () => { downloadCount: 4, previewImageName: 'cover.png', previewImagePath: 'uploads/brand-knowledge/cover.png', + previewImageSlices: [ + 'uploads/brand-knowledge/cover-001.webp', + 'uploads/brand-knowledge/cover-002.webp', + ], createdAt: new Date('2026-09-14T00:00:00.000Z'), uploader: { realName: '无邪', username: 'wuxie' }, likes: [{ id: 'like-1' }], @@ -125,6 +129,10 @@ describe('brand knowledge engagement routes', () => { isLiked: true, isFavorited: true, previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview', + previewUrls: [ + '/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=0', + '/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=1', + ], })) }) diff --git a/app/api/department-dashboard/brand-knowledge/route.test.ts b/app/api/department-dashboard/brand-knowledge/route.test.ts new file mode 100644 index 0000000..7b06e57 --- /dev/null +++ b/app/api/department-dashboard/brand-knowledge/route.test.ts @@ -0,0 +1,146 @@ +import { getServerSession } from 'next-auth' +import { rename, unlink, writeFile } from 'fs/promises' +import { prisma } from '@/lib/prisma' +import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview' + +const releaseRequestSlot = jest.fn() + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/brand-center-dashboard-access', () => ({ + BrandCenterDashboardAccessError: class BrandCenterDashboardAccessError extends Error { + constructor(message: string, readonly status = 403) { super(message) } + }, + 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/upload-paths', () => ({ getUploadRootDir: () => '/uploads' })) +jest.mock('@/lib/path-security', () => ({ + ensureSafeDirectoryWithinRoot: jest.fn(async () => '/uploads/brand-knowledge'), + isPathFreeOfSymbolicLinks: jest.fn(async () => true), + normalizeSafeFileName: (value: string) => value, + resolveStrictChildPath: (_root: string, name: string) => `/uploads/brand-knowledge/${name}`, +})) +jest.mock('@/lib/multipart-request-guard', () => ({ + assertBoundedMultipartHeaders: jest.fn(), + assertMultipartFiles: jest.fn(), +})) +jest.mock('@/lib/multipart-route-limits', () => ({ + boundedMultipartRequestLimiter: { acquire: jest.fn(async () => releaseRequestSlot) }, + getBoundedMultipartFailure: jest.fn(() => null), +})) +jest.mock('fs/promises', () => ({ + rename: jest.fn(async () => undefined), + unlink: jest.fn(async () => undefined), + writeFile: jest.fn(async () => undefined), +})) + +const { TextDecoder, TextEncoder } = require('util') as typeof import('util') +const { clearImmediate, setImmediate } = require('timers') as typeof import('timers') +Object.assign(globalThis, { TextDecoder, TextEncoder, clearImmediate, setImmediate }) +const edgePrimitives = require('next/dist/compiled/@edge-runtime/primitives') as typeof globalThis +Object.assign(globalThis, { + Headers: edgePrimitives.Headers, + Request: edgePrimitives.Request, + Response: edgePrimitives.Response, +}) + +let POST: typeof import('./route').POST + +const mainFile = { + name: 'knowledge.pdf', + size: 4, + type: 'application/pdf', + arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4]).buffer, +} +const previewFile = { + name: 'long.png', + size: 8, + type: 'image/png', + arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]).buffer, +} + +function uploadRequest() { + const values = new Map([ + ['title', '长图规范'], + ['knowledgeType', '影像规范'], + ['brand', '索菲亚'], + ['productCategoryName', '衣柜'], + ['file', mainFile], + ['previewImage', previewFile], + ]) + return { + headers: new Headers(), + formData: async () => ({ get: (name: string) => values.get(name) ?? null }), + } as unknown as Request +} + +function createdRecord(data: Record) { + return { + id: 'knowledge-1', + title: data.title, + knowledgeType: data.knowledgeType, + brand: data.brand, + productCategoryName: data.productCategoryName, + fileName: data.fileName, + mimeType: data.mimeType, + fileSize: data.fileSize, + downloadCount: 0, + previewImageName: data.previewImageName, + previewImagePath: data.previewImagePath, + previewImageMimeType: data.previewImageMimeType, + previewImageSize: data.previewImageSize, + createdAt: new Date('2026-09-14T00:00:00.000Z'), + uploader: { realName: '无邪', username: 'wuxie' }, + likes: [], + favorites: [], + _count: { likes: 0 }, + } +} + +describe('brand knowledge upload route', () => { + beforeAll(async () => { ({ POST } = await import('./route')) }) + + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) + ;(prepareBrandKnowledgePreview as jest.Mock).mockResolvedValue({ + source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 }, + slices: [Buffer.from('first-slice'), Buffer.from('second-slice')], + }) + ;(prisma.brandKnowledge.create as jest.Mock).mockImplementation(async ({ data }: { data: Record }) => createdRecord(data)) + }) + + test('stores ordered WebP slices and keeps the first slice as the card preview', async () => { + const response = await POST(uploadRequest()) + const call = (prisma.brandKnowledge.create as jest.Mock).mock.calls[0][0] + + expect(response.status).toBe(201) + expect(prepareBrandKnowledgePreview).toHaveBeenCalledWith(expect.any(Buffer), 'image/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$/) + expect(call.data.previewImagePath).toBe(call.data.previewImageSlices[0]) + expect(call.data.previewImageMimeType).toBe('image/webp') + expect(call.data.previewImageSize).toBe(BigInt('first-slice'.length)) + expect(writeFile).toHaveBeenCalledTimes(3) + expect(rename).toHaveBeenCalledTimes(3) + expect(releaseRequestSlot).toHaveBeenCalledTimes(1) + }) + + test('removes the source and every written slice when database persistence fails', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + ;(prisma.brandKnowledge.create as jest.Mock).mockRejectedValue(new Error('database unavailable')) + + const response = await POST(uploadRequest()) + const writtenDestinations = (rename as jest.Mock).mock.calls.map((call) => call[1] as string) + + expect(response.status).toBe(500) + for (const destination of writtenDestinations) { + expect(unlink).toHaveBeenCalledWith(destination) + } + expect(errorSpy).toHaveBeenCalled() + expect(releaseRequestSlot).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index 6e1e5e1..71c2231 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -10,7 +10,7 @@ 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 } from '@/lib/brand-image-validation' +import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview' import { isWebFileLike } from '@/lib/web-file' import { BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS, @@ -139,9 +139,9 @@ export async function GET(request: Request) { export async function POST(request: Request) { let storedPath: string | null = null - let storedPreviewPath: string | null = null + const storedPreviewPaths: string[] = [] let temporaryPath: string | null = null - let temporaryPreviewPath: string | null = null + const temporaryPreviewPaths: string[] = [] let releaseRequestSlot: (() => void) | null = null try { const session = await getServerSession(authOptions) @@ -180,25 +180,21 @@ export async function POST(request: Request) { 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 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS }) - let validatedPreview: Awaited> | null = null + let preparedPreview: Awaited> | null = null let previewImageName: string | null = null if (isWebFileLike(previewImage)) { if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) { 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, - { maxBytes: MAX_PREVIEW_IMAGE_SIZE, maxBytesLabel: '50MB' }, - ) + preparedPreview = await prepareBrandKnowledgePreview(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}` + previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${preparedPreview.source.extension}` } const safeOriginalName = normalizeSafeFileName(file.name) @@ -219,18 +215,21 @@ export async function POST(request: Request) { temporaryPath = null storedPath = `uploads/brand-knowledge/${storageName}` - let previewStorageName: string | null = null - if (isWebFileLike(previewImage) && validatedPreview) { - previewStorageName = `${randomUUID()}.${validatedPreview.extension}` - const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName) - if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) { - throw new Error('品牌知识预览图路径不可用') + if (isWebFileLike(previewImage) && preparedPreview) { + const previewBatchId = randomUUID() + for (let index = 0; index < preparedPreview.slices.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 }))) { + throw new Error('品牌知识预览图路径不可用') + } + const temporaryPreviewPath = `${previewDestination}.uploading` + temporaryPreviewPaths.push(temporaryPreviewPath) + await writeFile(temporaryPreviewPath, preparedPreview.slices[index], { flag: 'wx' }) + await rename(temporaryPreviewPath, previewDestination) + temporaryPreviewPaths.splice(temporaryPreviewPaths.indexOf(temporaryPreviewPath), 1) + storedPreviewPaths.push(`uploads/brand-knowledge/${previewStorageName}`) } - 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({ @@ -244,9 +243,10 @@ export async function POST(request: Request) { mimeType: file.type || 'application/octet-stream', fileSize: BigInt(file.size), previewImageName, - previewImagePath: storedPreviewPath, - previewImageMimeType: validatedPreview?.contentType || null, - previewImageSize: isWebFileLike(previewImage) ? BigInt(previewImage.size) : null, + previewImagePath: storedPreviewPaths[0] || null, + previewImageSlices: [...storedPreviewPaths], + previewImageMimeType: preparedPreview ? 'image/webp' : null, + previewImageSize: preparedPreview ? BigInt(preparedPreview.slices[0].length) : null, uploadedBy: userId, }, select: { @@ -271,15 +271,13 @@ export async function POST(request: Request) { }, }) storedPath = null - storedPreviewPath = null + storedPreviewPaths.length = 0 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) - } + await Promise.all(temporaryPreviewPaths.map((previewPath) => unlink(previewPath).catch(() => undefined))) if (storedPath) { try { const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, '')) @@ -288,14 +286,14 @@ export async function POST(request: Request) { // 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。 } } - if (storedPreviewPath) { + await Promise.all(storedPreviewPaths.map(async (previewPath) => { try { - const absolute = path.join(getUploadRootDir(), storedPreviewPath.replace(/^uploads\//, '')) + const absolute = path.join(getUploadRootDir(), previewPath.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/BrandKnowledgeDetail.test.tsx b/components/department-dashboard/BrandKnowledgeDetail.test.tsx index 202c253..8169d7a 100644 --- a/components/department-dashboard/BrandKnowledgeDetail.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDetail.test.tsx @@ -27,6 +27,10 @@ const item = { uploadedBy: '无邪', downloadUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/file', previewUrl: '/api/department-dashboard/brand-knowledge/knowledge-1/preview', + previewUrls: [ + '/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=0', + '/api/department-dashboard/brand-knowledge/knowledge-1/preview?part=1', + ], previewImageName: 'preview.png', likeCount: 2, isLiked: false, @@ -59,7 +63,8 @@ describe('BrandKnowledgeDetail', () => { render() expect(await screen.findByRole('heading', { level: 1, name: item.title })).toBeInTheDocument() - expect(screen.getByRole('img', { name: `预览图:${item.title}` })).toHaveClass('object-contain', 'object-center') + expect(screen.getAllByRole('img', { name: new RegExp(`预览图:${item.title}`) })).toHaveLength(2) + expect(screen.getByTestId('brand-knowledge-preview-slices')).toHaveClass('w-full') expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('索菲亚') expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('衣柜') expect(screen.getByRole('complementary', { name: '知识文件信息' })).toHaveTextContent('下载次数4') diff --git a/components/department-dashboard/BrandKnowledgeDetail.tsx b/components/department-dashboard/BrandKnowledgeDetail.tsx index 7e73907..6b310f6 100644 --- a/components/department-dashboard/BrandKnowledgeDetail.tsx +++ b/components/department-dashboard/BrandKnowledgeDetail.tsx @@ -28,6 +28,7 @@ type BrandKnowledgeItem = { uploadedBy: string downloadUrl: string previewUrl: string | null + previewUrls: string[] previewImageName: string | null likeCount: number isLiked: boolean @@ -250,17 +251,23 @@ export default function BrandKnowledgeDetail({ knowledgeId }: { knowledgeId: str
-
- {item.previewUrl ? ( - {`预览图:${item.title}`} +
+ {(item.previewUrls?.length ? item.previewUrls : item.previewUrl ? [item.previewUrl] : []).length > 0 ? ( +
+ {(item.previewUrls?.length ? item.previewUrls : [item.previewUrl as string]).map((previewUrl, index, previewUrls) => ( + {previewUrls.length 1 ? `预览图:${item.title}(第 ${index + 1} 片)` : `预览图:${item.title}`} + width={1600} + height={1500} + priority={index === 0} + unoptimized + className="block h-auto w-full" + sizes="(max-width: 1023px) 100vw, 70vw" + /> + ))} +
) : (