diff --git a/app/api/department-dashboard/brand-knowledge/route.test.ts b/app/api/department-dashboard/brand-knowledge/route.test.ts index a9041b2..88d30fa 100644 --- a/app/api/department-dashboard/brand-knowledge/route.test.ts +++ b/app/api/department-dashboard/brand-knowledge/route.test.ts @@ -1,11 +1,16 @@ import { getServerSession } from 'next-auth' -import { rename, unlink, writeFile } from 'fs/promises' +import { link, 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' import { isSuperAdmin } from '@/lib/department-permission' +import { resolveOwnedMergedUpload } from '@/lib/upload-sessions' +import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock' +import { readUploadSessionSeal } from '@/lib/material-upload-session-seal' +import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' const releaseRequestSlot = jest.fn() +const releaseUploadSessionLock = Object.assign(jest.fn(), { assertHeld: jest.fn() }) jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) @@ -15,7 +20,7 @@ jest.mock('@/lib/brand-center-dashboard-access', () => ({ }, assertBrandCenterDashboardAccess: jest.fn(async () => undefined), })) -jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { count: jest.fn(), create: jest.fn(), findMany: jest.fn() } } })) +jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { count: jest.fn(), create: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() } } })) jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(async () => false) })) jest.mock('@/lib/brand-knowledge-preview', () => ({ prepareBrandKnowledgePreview: jest.fn(), @@ -36,7 +41,18 @@ jest.mock('@/lib/multipart-route-limits', () => ({ boundedMultipartRequestLimiter: { acquire: jest.fn(async () => releaseRequestSlot) }, getBoundedMultipartFailure: jest.fn(() => null), })) +jest.mock('@/lib/upload-sessions', () => ({ + isSafeUploadId: (value: string) => /^upload-[A-Za-z0-9-]+$/.test(value), + resolveOwnedMergedUpload: jest.fn(), +})) +jest.mock('@/lib/material-upload-cross-process-lock', () => ({ + acquireTaskMaterialUploadSessionLocks: jest.fn(), +})) +jest.mock('@/lib/material-upload-session-seal', () => ({ readUploadSessionSeal: jest.fn() })) +jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn() })) jest.mock('fs/promises', () => ({ + link: jest.fn(async () => undefined), + lstat: jest.fn(), rename: jest.fn(async () => undefined), unlink: jest.fn(async () => undefined), writeFile: jest.fn(async () => undefined), @@ -55,12 +71,6 @@ Object.assign(globalThis, { let GET: typeof import('./route').GET 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, @@ -74,7 +84,10 @@ function uploadRequest() { ['knowledgeType', '影像规范'], ['brand', '索菲亚'], ['productCategoryName', '衣柜'], - ['file', mainFile], + ['uploadId', 'upload-1'], + ['existingFilePath', '/uploads/temp/upload-1/merged-knowledge.pdf'], + ['originalFileName', 'knowledge.pdf'], + ['fileSize', '4'], ['previewImage', previewFile], ]) return { @@ -103,7 +116,10 @@ function slicedUploadRequest() { ['knowledgeType', '影像规范'], ['brand', '索菲亚'], ['productCategoryName', '衣柜'], - ['file', mainFile], + ['uploadId', 'upload-1'], + ['existingFilePath', '/uploads/temp/upload-1/merged-knowledge.pdf'], + ['originalFileName', 'knowledge.pdf'], + ['fileSize', '4'], ['previewImageName', 'long.png'], ['previewImageSliceCount', '2'], ['previewImageSlices', [firstSlice, secondSlice]], @@ -131,6 +147,7 @@ function createdRecord(data: Record) { brand: data.brand, productCategoryName: data.productCategoryName, fileName: data.fileName, + filePath: data.filePath, mimeType: data.mimeType, fileSize: data.fileSize, downloadCount: 0, @@ -139,6 +156,7 @@ function createdRecord(data: Record) { previewImageMimeType: data.previewImageMimeType, previewImageSize: data.previewImageSize, createdAt: new Date('2026-09-14T00:00:00.000Z'), + uploadedBy: data.uploadedBy, uploader: { realName: '无邪', username: 'wuxie' }, likes: [], favorites: [], @@ -154,7 +172,16 @@ describe('brand knowledge upload route', () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) ;(prisma.brandKnowledge.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(0) + ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLock) + ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue({ + uploadId: 'upload-1', + filePath: '/uploads/temp/upload-1/merged-knowledge.pdf', + session: { fileName: 'knowledge.pdf', fileSize: 4, userId: 'user-1', uploadPurpose: 'brand-knowledge' }, + }) + ;(readUploadSessionSeal as jest.Mock).mockResolvedValue({ state: 'completed' }) + ;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true, status: 'deleted' }) ;(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')], @@ -200,22 +227,29 @@ describe('brand knowledge upload route', () => { 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(link).toHaveBeenCalledWith( + '/uploads/temp/upload-1/merged-knowledge.pdf', + '/uploads/brand-knowledge/source-upload-1.pdf', + ) + expect(call.data.filePath).toBe('uploads/brand-knowledge/source-upload-1.pdf') + expect(writeFile).toHaveBeenCalledTimes(2) + expect(rename).toHaveBeenCalledTimes(2) + expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1') + expect(releaseUploadSessionLock).toHaveBeenCalledTimes(1) expect(releaseRequestSlot).toHaveBeenCalledTimes(1) }) - test('removes the source and every written slice when database persistence fails', async () => { + test('removes the published hard link and preview slices 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(unlink).toHaveBeenCalledWith('/uploads/brand-knowledge/source-upload-1.pdf') + expect(unlink).toHaveBeenCalledWith('/uploads/brand-knowledge/upload-1-preview-part-001.webp') + expect(unlink).toHaveBeenCalledWith('/uploads/brand-knowledge/upload-1-preview-part-002.webp') + expect(cleanupConsumedUploadSession).not.toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled() expect(releaseRequestSlot).toHaveBeenCalledTimes(1) }) @@ -235,4 +269,88 @@ describe('brand knowledge upload route', () => { expect(call.data.previewImageSlices[0]).toMatch(/-part-001\.webp$/) expect(call.data.previewImageSlices[1]).toMatch(/-part-002\.webp$/) }) + + test('requires a completed upload seal before publishing the main file', async () => { + ;(readUploadSessionSeal as jest.Mock).mockResolvedValue({ state: 'sealing' }) + + const response = await POST(uploadRequest()) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error).toContain('尚未完成分片合并') + expect(link).not.toHaveBeenCalled() + expect(prisma.brandKnowledge.create).not.toHaveBeenCalled() + }) + + test('rejects a merged upload that does not belong to the current user', async () => { + ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue(null) + + const response = await POST(uploadRequest()) + + expect(response.status).toBe(403) + expect(link).not.toHaveBeenCalled() + expect(prisma.brandKnowledge.create).not.toHaveBeenCalled() + }) + + test('rejects a merged upload created for a different workflow', async () => { + ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue({ + uploadId: 'upload-1', + filePath: '/uploads/temp/upload-1/merged-knowledge.pdf', + session: { fileName: 'knowledge.pdf', fileSize: 4, userId: 'user-1' }, + }) + + const response = await POST(uploadRequest()) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error).toContain('上传用途') + expect(link).not.toHaveBeenCalled() + expect(prisma.brandKnowledge.create).not.toHaveBeenCalled() + }) + + test('replays the existing knowledge record without consuming the merged file twice', async () => { + const replayed = createdRecord({ + title: '长图规范', + knowledgeType: '影像规范', + brand: '索菲亚', + productCategoryName: '衣柜', + fileName: 'knowledge.pdf', + filePath: 'uploads/brand-knowledge/source-upload-1.pdf', + mimeType: 'application/pdf', + fileSize: BigInt(4), + previewImageName: null, + previewImagePath: null, + previewImageMimeType: null, + previewImageSize: null, + uploadedBy: 'user-1', + }) + ;(prisma.brandKnowledge.findFirst as jest.Mock).mockResolvedValue(replayed) + + const response = await POST(uploadRequest()) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.replayed).toBe(true) + expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() + expect(link).not.toHaveBeenCalled() + expect(prisma.brandKnowledge.create).not.toHaveBeenCalled() + expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1') + }) + + test('preserves published files when database commit state cannot be inspected', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + ;(prisma.brandKnowledge.create as jest.Mock).mockRejectedValue(new Error('connection reset')) + ;(prisma.brandKnowledge.findFirst as jest.Mock) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('database unavailable')) + + const response = await POST(uploadRequest()) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.code).toBe('BRAND_KNOWLEDGE_PERSISTENCE_UNKNOWN') + expect(unlink).not.toHaveBeenCalledWith('/uploads/brand-knowledge/source-upload-1.pdf') + expect(cleanupConsumedUploadSession).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalled() + }) }) diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index ca32738..fefb43d 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto' -import { rename, unlink, writeFile } from 'fs/promises' +import { link, lstat, rename, unlink, writeFile } from 'fs/promises' import path from 'path' +import { lookup as lookupMimeType } from 'mime-types' import { getServerSession } from 'next-auth' import { NextResponse } from 'next/server' import { authOptions } from '@/lib/auth' @@ -13,6 +14,13 @@ import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multi import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits' import { prepareBrandKnowledgePreview, validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview' import { isWebFileLike } from '@/lib/web-file' +import { isSafeUploadId, resolveOwnedMergedUpload } from '@/lib/upload-sessions' +import { + acquireTaskMaterialUploadSessionLocks, + type MaterialUploadStorageLockRelease, +} from '@/lib/material-upload-cross-process-lock' +import { readUploadSessionSeal } from '@/lib/material-upload-session-seal' +import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS, BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES, @@ -35,6 +43,13 @@ const KNOWLEDGE_TYPES: ReadonlySet = new Set(BRAND_KNOWLEDGE_TYPE_OPTION const ALLOWED_EXTENSIONS: ReadonlySet = new Set(BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS) const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' } +class BrandKnowledgePersistenceUnknownError extends Error { + constructor() { + super('品牌知识保存状态暂时无法确认,服务器已保留上传文件,请勿重新上传') + this.name = 'BrandKnowledgePersistenceUnknownError' + } +} + function accessErrorResponse(error: unknown) { if (error instanceof BrandCenterDashboardAccessError) { return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS }) @@ -46,6 +61,61 @@ function normalizeText(value: FormDataEntryValue | null, maxLength: number) { return typeof value === 'string' ? value.trim().slice(0, maxLength) : '' } +function getKnowledgeResponseSelect(userId: string) { + return { + id: true, + title: true, + knowledgeType: true, + brand: true, + productCategoryName: true, + fileName: true, + filePath: true, + mimeType: true, + fileSize: true, + downloadCount: true, + previewImageName: true, + previewImagePath: true, + previewImageMimeType: true, + previewImageSize: true, + createdAt: true, + uploadedBy: true, + uploader: { select: { realName: true, username: true } }, + likes: { where: { userId }, select: { id: true } }, + favorites: { where: { userId }, select: { id: true } }, + _count: { select: { likes: true } }, + } as const +} + +async function publishMergedUploadByHardLink(input: { + sourcePath: string + destination: string + expectedBytes: number +}) { + try { + await link(input.sourcePath, input.destination) + return + } catch (error: any) { + if (error?.code !== 'EEXIST') throw error + } + + const [sourceStats, destinationStats] = await Promise.all([ + lstat(input.sourcePath), + lstat(input.destination), + ]) + if ( + sourceStats.isSymbolicLink() + || destinationStats.isSymbolicLink() + || !sourceStats.isFile() + || !destinationStats.isFile() + || sourceStats.size !== input.expectedBytes + || destinationStats.size !== input.expectedBytes + || sourceStats.dev !== destinationStats.dev + || sourceStats.ino !== destinationStats.ino + ) { + throw new Error('品牌知识主文件目标路径已存在且与上传会话不一致') + } +} + function serializeKnowledge(item: { id: string title: string @@ -162,11 +232,15 @@ export async function GET(request: Request) { } export async function POST(request: Request) { - let storedPath: string | null = null + let uploadId = '' + let publishedMainPath: string | null = null const storedPreviewPaths: string[] = [] - let temporaryPath: string | null = null const temporaryPreviewPaths: string[] = [] let releaseRequestSlot: (() => void) | null = null + let releaseUploadSessionLock: MaterialUploadStorageLockRelease | null = null + const persistence: { + outcome: 'not_attempted' | 'not_committed' | 'committed' | 'unknown' + } = { outcome: 'not_attempted' } try { const session = await getServerSession(authOptions) await assertBrandCenterDashboardAccess(session) @@ -178,7 +252,6 @@ export async function POST(request: Request) { const formData = await request.formData() assertMultipartFiles(formData, { fields: { - file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE }, previewImage: { maxCount: 1, maxFileBytes: MAX_PREVIEW_IMAGE_SIZE, @@ -191,15 +264,11 @@ export async function POST(request: Request) { allowedMimeTypes: ['image/webp'], }, }, - maxFiles: 1 + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, - maxTotalBytes: MAX_FILE_SIZE + MAX_PREVIEW_IMAGE_SIZE, + maxFiles: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, + maxTotalBytes: 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 }) } @@ -213,9 +282,86 @@ export async function POST(request: Request) { const knowledgeType = normalizeText(formData.get('knowledgeType'), 30) const brand = normalizeText(formData.get('brand'), 40) const productCategoryName = normalizeText(formData.get('productCategoryName'), 80) + uploadId = normalizeText(formData.get('uploadId'), 128) + const existingFilePath = normalizeText(formData.get('existingFilePath'), 4096) + const safeOriginalName = normalizeSafeFileName(normalizeText(formData.get('originalFileName'), 255)) + const declaredFileSize = Number(normalizeText(formData.get('fileSize'), 20)) 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 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS }) + if (!isSafeUploadId(uploadId) || !existingFilePath) { + return NextResponse.json({ success: false, error: '请先完成知识主文件的分片上传' }, { status: 400, headers: PRIVATE_HEADERS }) + } + if (!safeOriginalName) return NextResponse.json({ success: false, error: '文件名不合法' }, { status: 400, headers: PRIVATE_HEADERS }) + if (!Number.isSafeInteger(declaredFileSize) || declaredFileSize <= 0 || declaredFileSize > MAX_FILE_SIZE) { + return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS }) + } + const extension = path.extname(safeOriginalName).toLowerCase() + if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '文件格式不支持,请参考素材资产库支持的格式' }, { status: 400, headers: PRIVATE_HEADERS }) + + releaseUploadSessionLock = await acquireTaskMaterialUploadSessionLocks([uploadId]) + releaseUploadSessionLock.assertHeld() + + const uploadRoot = getUploadRootDir() + const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge') + if (!knowledgeDirectory) throw new Error('品牌知识存储目录不可用') + const storageName = `source-${uploadId}${extension}` + const destination = resolveStrictChildPath(knowledgeDirectory, storageName) + if (!destination) throw new Error('品牌知识文件路径不可用') + if (!(await isPathFreeOfSymbolicLinks(uploadRoot, destination, { allowMissingLeaf: true }))) throw new Error('品牌知识存储目录不安全') + const storedPath = `uploads/brand-knowledge/${storageName}` + const responseSelect = getKnowledgeResponseSelect(userId) + const replayed = await prisma.brandKnowledge.findFirst({ + where: { filePath: storedPath }, + select: responseSelect, + }) + if (replayed) { + if ( + replayed.uploadedBy !== userId + || replayed.fileName !== safeOriginalName + || replayed.fileSize !== BigInt(declaredFileSize) + ) { + return NextResponse.json( + { success: false, error: '该上传会话已被其它品牌知识记录使用' }, + { status: 409, headers: PRIVATE_HEADERS }, + ) + } + await cleanupConsumedUploadSession(uploadId).catch((error) => { + console.warn('[department-dashboard] 品牌知识重放请求清理上传会话失败:', { uploadId, error }) + }) + return NextResponse.json( + { success: true, data: serializeKnowledge(replayed), replayed: true }, + { status: 201, headers: PRIVATE_HEADERS }, + ) + } + + const ownedMergedUpload = await resolveOwnedMergedUpload({ + existingFilePath, + uploadId, + userId, + }) + if (!ownedMergedUpload) { + return NextResponse.json( + { success: false, error: '上传会话不存在、已失效或不属于当前用户' }, + { status: 403, headers: PRIVATE_HEADERS }, + ) + } + const seal = await readUploadSessionSeal(uploadId) + if (seal?.state !== 'completed') { + return NextResponse.json( + { success: false, error: '知识主文件尚未完成分片合并' }, + { status: 409, headers: PRIVATE_HEADERS }, + ) + } + if ( + normalizeSafeFileName(ownedMergedUpload.session.fileName) !== safeOriginalName + || ownedMergedUpload.session.fileSize !== declaredFileSize + || ownedMergedUpload.session.uploadPurpose !== 'brand-knowledge' + ) { + return NextResponse.json( + { success: false, error: '知识主文件信息或上传用途与会话不一致' }, + { status: 409, headers: PRIVATE_HEADERS }, + ) + } let preparedPreview: Awaited> | null = null let preparedUploadedSlices: Awaited> | null = null @@ -254,36 +400,24 @@ export async function POST(request: Request) { previewImageName = normalizeSafeFileName(normalizeText(formData.get('previewImageName'), 255)) || 'preview.webp' } - 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: '文件格式不支持,请参考素材资产库支持的格式' }, { status: 400, headers: PRIVATE_HEADERS }) - - const uploadRoot = getUploadRootDir() - const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge') - if (!knowledgeDirectory) throw new Error('品牌知识存储目录不可用') - const storageName = `${randomUUID()}${extension}` - const destination = resolveStrictChildPath(knowledgeDirectory, storageName) - if (!destination) throw new Error('品牌知识文件路径不可用') - if (!(await isPathFreeOfSymbolicLinks(uploadRoot, destination, { allowMissingLeaf: true }))) throw new Error('品牌知识存储目录不安全') - temporaryPath = `${destination}.uploading` - await writeFile(temporaryPath, Buffer.from(await file.arrayBuffer()), { flag: 'wx' }) - await rename(temporaryPath, destination) - temporaryPath = null - storedPath = `uploads/brand-knowledge/${storageName}` + await publishMergedUploadByHardLink({ + sourcePath: ownedMergedUpload.filePath, + destination, + expectedBytes: declaredFileSize, + }) + publishedMainPath = destination const previewSlices = preparedUploadedSlices ? preparedUploadedSlices.slices.map((slice) => slice.buffer) : preparedPreview?.slices || [] if (previewSlices.length > 0) { - const previewBatchId = randomUUID() for (let index = 0; index < previewSlices.length; index += 1) { - const previewStorageName = `${previewBatchId}-part-${String(index + 1).padStart(3, '0')}.webp` + const previewStorageName = `${uploadId}-preview-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` + const temporaryPreviewPath = `${previewDestination}.uploading-${randomUUID()}` temporaryPreviewPaths.push(temporaryPreviewPath) await writeFile(temporaryPreviewPath, previewSlices[index], { flag: 'wx' }) await rename(temporaryPreviewPath, previewDestination) @@ -292,70 +426,73 @@ export async function POST(request: Request) { } } - 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: storedPreviewPaths[0] || null, - previewImageSlices: [...storedPreviewPaths], - previewImageMimeType: previewSlices.length > 0 ? 'image/webp' : null, - previewImageSize: previewSlices.length > 0 ? BigInt(previewSlices[0].length) : null, - uploadedBy: userId, - }, - select: { - id: true, - title: true, - knowledgeType: true, - brand: true, - productCategoryName: true, - fileName: true, - mimeType: true, - fileSize: true, - downloadCount: true, - previewImageName: true, - previewImagePath: true, - previewImageMimeType: true, - previewImageSize: true, - createdAt: true, - uploader: { select: { realName: true, username: true } }, - likes: { select: { id: true } }, - favorites: { select: { id: true } }, - _count: { select: { likes: true } }, - }, - }) - storedPath = null + let created + persistence.outcome = 'not_committed' + try { + created = await prisma.brandKnowledge.create({ + data: { + title, + knowledgeType, + brand, + productCategoryName, + fileName: safeOriginalName, + filePath: storedPath, + mimeType: String(lookupMimeType(safeOriginalName) || 'application/octet-stream'), + fileSize: BigInt(declaredFileSize), + previewImageName, + previewImagePath: storedPreviewPaths[0] || null, + previewImageSlices: [...storedPreviewPaths], + previewImageMimeType: previewSlices.length > 0 ? 'image/webp' : null, + previewImageSize: previewSlices.length > 0 ? BigInt(previewSlices[0].length) : null, + uploadedBy: userId, + }, + select: responseSelect, + }) + persistence.outcome = 'committed' + } catch (persistenceError) { + let confirmed + try { + confirmed = await prisma.brandKnowledge.findFirst({ + where: { filePath: storedPath, uploadedBy: userId }, + select: responseSelect, + }) + } catch (inspectionError) { + persistence.outcome = 'unknown' + console.error('[department-dashboard] 品牌知识保存异常后无法确认数据库状态:', { + uploadId, + persistenceError, + inspectionError, + }) + throw new BrandKnowledgePersistenceUnknownError() + } + if (!confirmed) throw persistenceError + created = confirmed + persistence.outcome = 'committed' + } + + publishedMainPath = null storedPreviewPaths.length = 0 + await cleanupConsumedUploadSession(uploadId).catch((error) => { + console.warn('[department-dashboard] 品牌知识已保存但上传会话清理失败:', { uploadId, error }) + }) return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS }) } catch (error) { - if (temporaryPath) { - await unlink(temporaryPath).catch(() => undefined) - } - await Promise.all(temporaryPreviewPaths.map((previewPath) => unlink(previewPath).catch(() => undefined))) - if (storedPath) { - try { - const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, '')) - await unlink(absolute) - } catch { - // 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。 - } - } - await Promise.all(storedPreviewPaths.map(async (previewPath) => { - try { + if (persistence.outcome !== 'committed' && persistence.outcome !== 'unknown') { + await Promise.all(temporaryPreviewPaths.map((previewPath) => unlink(previewPath).catch(() => undefined))) + if (publishedMainPath) await unlink(publishedMainPath).catch(() => undefined) + await Promise.all(storedPreviewPaths.map(async (previewPath) => { const absolute = path.join(getUploadRootDir(), previewPath.replace(/^uploads\//, '')) - await unlink(absolute) - } catch { - // Keep the original error if cleanup of an orphan preview fails. - } - })) + await unlink(absolute).catch(() => undefined) + })) + } const response = accessErrorResponse(error) if (response) return response + if (error instanceof BrandKnowledgePersistenceUnknownError) { + return NextResponse.json( + { success: false, error: error.message, code: 'BRAND_KNOWLEDGE_PERSISTENCE_UNKNOWN' }, + { status: 503, headers: PRIVATE_HEADERS }, + ) + } const boundedFailure = getBoundedMultipartFailure(error) if (boundedFailure) { return NextResponse.json( @@ -366,6 +503,7 @@ export async function POST(request: Request) { console.error('[department-dashboard] 品牌知识上传失败:', error) return NextResponse.json({ success: false, error: '品牌知识上传失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS }) } finally { + await releaseUploadSessionLock?.() releaseRequestSlot?.() } } diff --git a/app/api/materials/upload/init/route.test.ts b/app/api/materials/upload/init/route.test.ts index d31e608..ccbdf0d 100644 --- a/app/api/materials/upload/init/route.test.ts +++ b/app/api/materials/upload/init/route.test.ts @@ -43,6 +43,7 @@ jest.mock('crypto', () => ({ function createRequest(body: unknown, options?: { jsonReject?: boolean }) { return { + headers: { get: jest.fn(() => null) }, json: jest.fn().mockImplementation(() => { if (options?.jsonReject) { return Promise.reject(new Error('invalid json')) @@ -159,6 +160,42 @@ describe('/api/materials/upload/init', () => { ) }) + test('品牌知识上传会持久化明确的上传用途', async () => { + const response = await POST(createRequest({ + fileName: 'brand-guide.pdf', + fileSize: 2048, + totalChunks: 2, + chunkSize: 1024, + uploadPurpose: 'brand-knowledge', + })) + + expect(response.status).toBe(200) + expect(writeFile).toHaveBeenCalledWith( + expect.stringMatching(/meta\.json$/), + expect.stringContaining('"uploadPurpose":"brand-knowledge"'), + 'utf-8', + ) + expect(saveUploadSession).toHaveBeenCalledWith( + expect.stringContaining('upload-'), + expect.objectContaining({ uploadPurpose: 'brand-knowledge' }), + ) + }) + + test('拒绝未登记的上传用途', async () => { + const response = await POST(createRequest({ + fileName: 'brand-guide.pdf', + fileSize: 2048, + totalChunks: 2, + chunkSize: 1024, + uploadPurpose: 'another-workflow', + })) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: '上传用途无效' }) + expect(ensureSafeDirectoryWithinRoot).not.toHaveBeenCalled() + expect(saveUploadSession).not.toHaveBeenCalled() + }) + test('拒绝总大小与分片数量不可能对应的伪造会话', async () => { const response = await POST(createRequest({ fileName: 'forged.psd', diff --git a/app/api/materials/upload/init/route.ts b/app/api/materials/upload/init/route.ts index a825ddf..4e45f96 100644 --- a/app/api/materials/upload/init/route.ts +++ b/app/api/materials/upload/init/route.ts @@ -18,6 +18,7 @@ import { const MEBIBYTE = 1024 * 1024 const GIBIBYTE = 1024 * MEBIBYTE const MIN_FREE_SPACE_RESERVE_BYTES = 2 * GIBIBYTE +const ALLOWED_UPLOAD_PURPOSES = new Set(['brand-knowledge']) export async function POST(req: NextRequest) { try { @@ -41,6 +42,9 @@ export async function POST(req: NextRequest) { const normalizedFileName = normalizeSafeFileName(rawFileName) const normalizedFileSize = Number(body?.fileSize) const normalizedTotalChunks = Number(body?.totalChunks) + const uploadPurpose = typeof body?.uploadPurpose === 'string' + ? body.uploadPurpose.trim() + : '' if (!rawFileName.trim()) { return NextResponse.json({ error: '文件名不能为空' }, { status: 400 }) @@ -48,6 +52,9 @@ export async function POST(req: NextRequest) { if (!normalizedFileName) { return NextResponse.json({ error: '文件名包含不安全的路径或字符' }, { status: 400 }) } + if (uploadPurpose && !ALLOWED_UPLOAD_PURPOSES.has(uploadPurpose)) { + return NextResponse.json({ error: '上传用途无效' }, { status: 400 }) + } if (!Number.isSafeInteger(normalizedFileSize) || normalizedFileSize <= 0) { return NextResponse.json({ error: '上传文件不能为空' }, { status: 400 }) @@ -118,6 +125,7 @@ export async function POST(req: NextRequest) { totalChunks: normalizedTotalChunks, chunkSize: normalizedChunkSize, userId, + ...(uploadPurpose ? { uploadPurpose } : {}), createdAt: Date.now(), } await writeFile(metaPath, JSON.stringify(metaPayload), 'utf-8') @@ -137,6 +145,7 @@ export async function POST(req: NextRequest) { _speedSampleAt: now, _speedSampleBytes: 0, userId, + ...(uploadPurpose ? { uploadPurpose } : {}), }) console.log(`[UploadInit] Created upload session: ${uploadId}, chunks: ${normalizedTotalChunks}`) diff --git a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx index c1c949b..9cd2dd4 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx @@ -3,12 +3,18 @@ import BrandKnowledgeDashboard from './BrandKnowledgeDashboard' import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types' const mockPrepareBrandKnowledgePreviewInBrowser = jest.fn() +const mockChunkUpload = jest.fn() jest.mock('@/lib/brand-knowledge-image-browser', () => ({ prepareBrandKnowledgePreviewInBrowser: (...args: unknown[]) => mockPrepareBrandKnowledgePreviewInBrowser(...args), canUseBrandKnowledgeLegacyServerPreview: jest.fn(async () => false), })) +jest.mock('@/lib/chunk-upload', () => ({ + chunkUpload: (...args: unknown[]) => mockChunkUpload(...args), + generateFileId: (file: File) => `file-${file.name}-${file.size}-${file.lastModified}`, +})) + jest.mock('next-auth/react', () => ({ useSession: () => ({ data: { user: { name: '本地超级管理员' } } }), signOut: jest.fn(), @@ -16,9 +22,17 @@ jest.mock('next-auth/react', () => ({ describe('BrandKnowledgeDashboard', () => { beforeEach(() => { + localStorage.clear() 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) } }) + mockChunkUpload.mockReset() + mockChunkUpload.mockResolvedValue({ + uploadId: 'upload-knowledge-1', + existingFilePath: '/uploads/temp/upload-knowledge-1/merged-brand-guide.pdf', + fileName: 'brand-guide.pdf', + fileSize: 12, + }) mockPrepareBrandKnowledgePreviewInBrowser.mockResolvedValue([ { file: new File(['slice one'], '预览图-001.webp', { type: 'image/webp' }), @@ -281,11 +295,26 @@ describe('BrandKnowledgeDashboard', () => { 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) }))) + expect(mockChunkUpload).toHaveBeenCalledWith( + knowledgeFile, + { uploadPurpose: 'brand-knowledge' }, + expect.objectContaining({ + maxRetries: 5, + onProgress: expect.any(Function), + onStageChange: expect.any(Function), + isCancelled: expect.any(Function), + onActiveXhrChange: expect.any(Function), + }), + ) 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('file')).toBeNull() + expect(submittedFormData.get('uploadId')).toBe('upload-knowledge-1') + expect(submittedFormData.get('existingFilePath')).toBe('/uploads/temp/upload-knowledge-1/merged-brand-guide.pdf') + expect(submittedFormData.get('originalFileName')).toBe('品牌手册.pdf') + expect(submittedFormData.get('fileSize')).toBe(String(knowledgeFile.size)) expect(submittedFormData.get('previewImage')).toBeNull() expect(submittedFormData.get('previewImageName')).toBe('预览图.png') expect(submittedFormData.get('previewImageSliceCount')).toBe('2') @@ -294,8 +323,7 @@ describe('BrandKnowledgeDashboard', () => { '预览图-002.webp', ]) - fireEvent.click(screen.getByRole('button', { name: '关闭上传知识窗口' })) - expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()) expect(uploadButton).toHaveFocus() }) @@ -321,4 +349,147 @@ describe('BrandKnowledgeDashboard', () => { expect(screen.getByRole('status')).toHaveTextContent('图片高度不超过 3000px,已保留为单张预览') expect(screen.queryByText(/已切为 1 片/)).not.toBeInTheDocument() }) + + it.each([ + [413, '上传内容超过服务器允许大小(413)'], + [429, '当前上传任务较多(429)'], + [502, '上传服务网关异常(502)'], + [503, '上传服务暂不可用(503)'], + [504, '服务器处理超时(504)'], + ])('shows an actionable message for a non-JSON %i upload response', async (status, expectedMessage) => { + const fetchMock = global.fetch as jest.Mock + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/product-categories') { + return Promise.resolve({ ok: true, json: async () => [] } as Response) + } + if (init?.method === 'POST') { + return Promise.resolve({ + ok: false, + status, + json: async () => { throw new Error('non-JSON upstream response') }, + } as unknown as Response) + } + return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) + }) + render() + + fireEvent.click(screen.getByRole('button', { name: '上传知识' })) + fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } }) + fireEvent.change(screen.getByLabelText('选择知识文件'), { + target: { files: [new File(['guide'], '品牌手册.pdf', { type: 'application/pdf' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: '提交知识' })) + + expect(await screen.findByRole('alert')).toHaveTextContent(expectedMessage) + expect(screen.getByRole('button', { name: '重新上传' })).toBeEnabled() + }) + + it('retries finalize without uploading the completed main file again', async () => { + const fetchMock = global.fetch as jest.Mock + let postCount = 0 + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/product-categories') { + return Promise.resolve({ ok: true, json: async () => [] } as Response) + } + if (init?.method === 'POST') { + postCount += 1 + if (postCount === 1) { + return Promise.resolve({ + ok: false, + status: 504, + json: async () => { throw new Error('gateway timeout') }, + } as unknown as Response) + } + return Promise.resolve({ ok: true, status: 201, json: async () => ({ success: true, data: {} }) } as Response) + } + return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) + }) + render() + + fireEvent.click(screen.getByRole('button', { name: '上传知识' })) + fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } }) + fireEvent.change(screen.getByLabelText('选择知识文件'), { + target: { files: [new File(['guide'], '品牌手册.pdf', { type: 'application/pdf' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: '提交知识' })) + expect(await screen.findByText(/服务器处理超时(504)/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '重新上传' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()) + expect(mockChunkUpload).toHaveBeenCalledTimes(1) + expect(postCount).toBe(2) + }) + + it('restores a completed main-file upload after remount and removes it after successful creation', async () => { + const fetchMock = global.fetch as jest.Mock + let finalizeCount = 0 + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/product-categories') { + return Promise.resolve({ ok: true, json: async () => [] } as Response) + } + if (init?.method === 'POST') { + finalizeCount += 1 + if (finalizeCount === 1) { + return Promise.resolve({ + ok: false, + status: 504, + json: async () => { throw new Error('gateway timeout') }, + } as unknown as Response) + } + return Promise.resolve({ ok: true, status: 201, json: async () => ({ success: true, data: {} }) } as Response) + } + return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) + }) + const file = new File(['guide'], '品牌手册.pdf', { + type: 'application/pdf', + lastModified: 123456, + }) + + const firstRender = render() + fireEvent.click(screen.getByRole('button', { name: '上传知识' })) + fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } }) + fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [file] } }) + fireEvent.click(screen.getByRole('button', { name: '提交知识' })) + expect(await screen.findByText(/服务器处理超时(504)/)).toBeInTheDocument() + expect(localStorage.length).toBe(1) + firstRender.unmount() + + render() + fireEvent.click(screen.getByRole('button', { name: '上传知识' })) + fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } }) + fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [file] } }) + expect(within(screen.getByRole('dialog', { name: '上传知识' })).getByRole('status')).toHaveTextContent('已恢复这个文件完成的分片上传') + fireEvent.click(screen.getByRole('button', { name: '提交知识' })) + + await waitFor(() => expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()) + expect(mockChunkUpload).toHaveBeenCalledTimes(1) + expect(finalizeCount).toBe(2) + expect(localStorage.length).toBe(0) + }) + + it('shows chunk progress and aborts active requests when the user cancels', async () => { + const abort = jest.fn() + mockChunkUpload.mockImplementation((_file, _metadata, options) => new Promise((_resolve, reject) => { + abort.mockImplementation(() => reject(new Error('上传已取消'))) + options.onProgress(37, 2 * 1024 * 1024) + options.onActiveXhrChange(new Set([{ abort }])) + })) + render() + + fireEvent.click(screen.getByRole('button', { name: '上传知识' })) + fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } }) + fireEvent.change(screen.getByLabelText('选择知识文件'), { + target: { files: [new File(['guide'], '品牌手册.pdf', { type: 'application/pdf' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: '提交知识' })) + + const progress = await screen.findByRole('progressbar', { name: '主文件上传进度' }) + expect(progress).toHaveAttribute('aria-valuenow', '37') + expect(progress).toHaveTextContent('2.0 MB/s') + fireEvent.click(screen.getByRole('button', { name: '取消上传' })) + + expect(abort).toHaveBeenCalledTimes(1) + expect(await screen.findByRole('alert')).toHaveTextContent('上传已取消') + expect(screen.getByRole('button', { name: '重新上传' })).toBeEnabled() + }) }) diff --git a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx index d366bf6..4fbd58c 100644 --- a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx +++ b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx @@ -1,7 +1,7 @@ 'use client' import Image from 'next/image' -import { useEffect, useRef, useState, type DragEvent, type RefObject } from 'react' +import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react' import { AlertCircle, FileText, FolderOpen, ImagePlus, Upload, X } from 'lucide-react' import { BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT, @@ -17,6 +17,7 @@ import { canUseBrandKnowledgeLegacyServerPreview, type BrandKnowledgeBrowserPreviewSlice, } from '@/lib/brand-knowledge-image-browser' +import { chunkUpload, generateFileId } from '@/lib/chunk-upload' import { isPerformanceSurveyBrowserCapabilityError } from '@/lib/performance-survey-image-error' const MAX_MAIN_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES @@ -25,6 +26,78 @@ const PREVIEW_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']) interface ProductCategory { id: string; name: string } +interface CompletedChunkUpload { + uploadId: string + existingFilePath: string + originalFileName: string + fileSize: number +} + +interface PersistedCompletedChunkUpload extends CompletedChunkUpload { + completedAt: number +} + +const COMPLETED_UPLOAD_STORAGE_PREFIX = 'brand-knowledge-completed-upload:v1:' +const COMPLETED_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +function completedUploadStorageKey(file: File) { + return `${COMPLETED_UPLOAD_STORAGE_PREFIX}${generateFileId(file)}` +} + +function removePersistedCompletedUpload(file: File) { + try { + localStorage.removeItem(completedUploadStorageKey(file)) + } catch { + // Browser storage can be unavailable in private or restricted contexts. + } +} + +function persistCompletedUpload(file: File, upload: CompletedChunkUpload) { + try { + localStorage.setItem(completedUploadStorageKey(file), JSON.stringify({ + ...upload, + completedAt: Date.now(), + } satisfies PersistedCompletedChunkUpload)) + } catch { + // Upload can still finish in the current dialog when storage is unavailable. + } +} + +function restorePersistedCompletedUpload(file: File): CompletedChunkUpload | null { + try { + const key = completedUploadStorageKey(file) + const raw = localStorage.getItem(key) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial + const valid = typeof parsed.uploadId === 'string' + && /^upload-[A-Za-z0-9_-]+$/.test(parsed.uploadId) + && typeof parsed.existingFilePath === 'string' + && parsed.existingFilePath.length > 0 + && parsed.originalFileName === file.name + && parsed.fileSize === file.size + && typeof parsed.completedAt === 'number' + && Date.now() - parsed.completedAt >= 0 + && Date.now() - parsed.completedAt <= COMPLETED_UPLOAD_MAX_AGE_MS + if (!valid) { + localStorage.removeItem(key) + return null + } + return { + uploadId: parsed.uploadId!, + existingFilePath: parsed.existingFilePath!, + originalFileName: parsed.originalFileName!, + fileSize: parsed.fileSize!, + } + } catch { + removePersistedCompletedUpload(file) + return null + } +} + +interface UploadError extends Error { + status?: number +} + interface BrandKnowledgeUploadDialogProps { onClose: () => void onUploaded: () => void | Promise @@ -41,6 +114,40 @@ function extensionOf(file: File) { return file.name.split('.').pop()?.toLowerCase() || '' } +function formatUploadSpeed(bytesPerSecond: number | null) { + if (!bytesPerSecond || bytesPerSecond <= 0) return '' + return `${formatFileSize(bytesPerSecond)}/s` +} + +function uploadErrorMessage(status: number, serverMessage?: string) { + if (status === 413) return '上传内容超过服务器允许大小(413)。请移除预览图后重试,或联系管理员检查上传限制。' + if (status === 429) return '当前上传任务较多(429),请稍后点击“重新上传”。' + if (status === 502) return '上传服务网关异常(502),请稍后点击“重新上传”。' + if (status === 503) return '上传服务暂不可用(503),请稍后点击“重新上传”。' + if (status === 504) return '服务器处理超时(504),请稍后点击“重新上传”。' + return serverMessage || '品牌知识上传失败,请稍后重试' +} + +async function assertUploadResponse(response: Response) { + const payload = await response.json().catch(() => null) + if (response.ok && payload?.success) return + const error = new Error(uploadErrorMessage(response.status, payload?.error)) as UploadError + error.status = response.status + throw error +} + +function thrownUploadErrorMessage(error: unknown, wasCancelled: boolean) { + if (wasCancelled || (error instanceof Error && (error.name === 'AbortError' || error.message === '上传已取消'))) { + return '上传已取消。文件和表单信息已保留,可点击“重新上传”。' + } + const status = error && typeof error === 'object' && 'status' in error + ? Number((error as UploadError).status) + : 0 + if (status > 0) return uploadErrorMessage(status, error instanceof Error ? error.message : undefined) + if (error instanceof TypeError) return '网络连接中断,请检查网络后点击“重新上传”。' + return error instanceof Error ? error.message : '品牌知识上传失败,请稍后重试' +} + export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, returnFocusRef }: BrandKnowledgeUploadDialogProps) { const [knowledgeType, setKnowledgeType] = useState(BRAND_KNOWLEDGE_TYPE_OPTIONS[0]) const [title, setTitle] = useState('') @@ -56,6 +163,9 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const [isDragging, setIsDragging] = useState(false) const [isPreviewDragging, setIsPreviewDragging] = useState(false) const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(null) + const [uploadSpeed, setUploadSpeed] = useState(null) + const [uploadStageMessage, setUploadStageMessage] = useState('') const [errorMessage, setErrorMessage] = useState('') const dialogRef = useRef(null) const closeButtonRef = useRef(null) @@ -65,6 +175,28 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const previewDragDepthRef = useRef(0) const previewProcessingAbortRef = useRef(null) const previewProcessingVersionRef = useRef(0) + const submitAbortRef = useRef(null) + const activeUploadXhrsRef = useRef>(new Set()) + const uploadCancelledRef = useRef(false) + const isUploadActiveRef = useRef(false) + const completedChunkUploadRef = useRef(null) + + const cancelActiveUpload = useCallback(() => { + if (!isUploadActiveRef.current) return + uploadCancelledRef.current = true + submitAbortRef.current?.abort() + activeUploadXhrsRef.current.forEach((xhr) => xhr.abort()) + activeUploadXhrsRef.current.clear() + setUploadStageMessage('正在取消上传…') + }, []) + + const requestClose = useCallback(() => { + if (isUploadActiveRef.current) { + cancelActiveUpload() + return + } + onClose() + }, [cancelActiveUpload, onClose]) useEffect(() => { const previousOverflow = document.body.style.overflow @@ -75,7 +207,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() - onClose() + requestClose() return } if (event.key !== 'Tab' || !dialogRef.current) return @@ -92,11 +224,14 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return document.addEventListener('keydown', handleKeyDown) return () => { previewProcessingAbortRef.current?.abort() + submitAbortRef.current?.abort() + activeUploadXhrsRef.current.forEach((xhr) => xhr.abort()) + activeUploadXhrsRef.current.clear() document.body.style.overflow = previousOverflow document.removeEventListener('keydown', handleKeyDown) returnFocusElement?.focus() } - }, [onClose, returnFocusRef]) + }, [requestClose, returnFocusRef]) useEffect(() => { let cancelled = false @@ -114,6 +249,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return }, [previewUrl]) const setFile = (file: File | undefined) => { + if (isUploadActiveRef.current) return if (!file) return if (!BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT.split(',').includes(`.${extensionOf(file)}`)) { setErrorMessage(`主文件格式不支持,请上传${BRAND_KNOWLEDGE_MAIN_FILE_LABEL}`) @@ -123,11 +259,17 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return setErrorMessage('主文件需为 1B 至 1GB 之间的有效文件') return } + const restoredUpload = restorePersistedCompletedUpload(file) + completedChunkUploadRef.current = restoredUpload + setUploadProgress(null) + setUploadSpeed(null) + setUploadStageMessage(restoredUpload ? '已恢复这个文件完成的分片上传,提交时将直接保存知识信息' : '') setErrorMessage('') setSelectedFile(file) } const setPreview = async (file: File | undefined) => { + if (isUploadActiveRef.current) return if (!file) return const extension = extensionOf(file) if (!PREVIEW_EXTENSIONS.has(extension) || (file.type && !['image/jpeg', 'image/png', 'image/webp'].includes(file.type))) { @@ -180,6 +322,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return } const removePreview = () => { + if (isUploadActiveRef.current) return previewProcessingVersionRef.current += 1 previewProcessingAbortRef.current?.abort() previewProcessingAbortRef.current = null @@ -191,6 +334,16 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return setErrorMessage('') } + const removeFile = () => { + if (isUploadActiveRef.current) return + if (selectedFile) removePersistedCompletedUpload(selectedFile) + completedChunkUploadRef.current = null + setSelectedFile(null) + setUploadProgress(null) + setUploadStageMessage('') + setErrorMessage('') + } + const handleDragEnter = (event: DragEvent) => { event.preventDefault(); dragDepthRef.current += 1; setIsDragging(true) } const handleDragLeave = (event: DragEvent) => { event.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) { dragDepthRef.current = 0; setIsDragging(false) } } const handlePreviewDragEnter = (event: DragEvent) => { event.preventDefault(); previewDragDepthRef.current += 1; setIsPreviewDragging(true) } @@ -199,15 +352,61 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const handleSubmit = async () => { if (isUploading || !selectedFile || !title.trim() || (previewImage && previewSlices.length === 0 && !previewUsesServerFallback)) return + uploadCancelledRef.current = false + isUploadActiveRef.current = true setIsUploading(true) + setUploadProgress(0) + setUploadSpeed(null) + setUploadStageMessage('正在分片上传主文件…') setErrorMessage('') try { + let completedChunkUpload = completedChunkUploadRef.current + if (completedChunkUpload + && (completedChunkUpload.originalFileName !== selectedFile.name || completedChunkUpload.fileSize !== selectedFile.size)) { + completedChunkUpload = null + completedChunkUploadRef.current = null + } + + if (!completedChunkUpload) { + const result = await chunkUpload(selectedFile, { uploadPurpose: 'brand-knowledge' }, { + maxRetries: 5, + onProgress: (progress, speed) => { + setUploadProgress(Math.max(0, Math.min(100, Math.round(progress)))) + setUploadSpeed(speed || null) + }, + onStageChange: (stage) => { + setUploadStageMessage(stage === 'processing' ? '主文件已上传,正在合并分片…' : '正在分片上传主文件…') + }, + isCancelled: () => uploadCancelledRef.current, + onActiveXhrChange: (xhrs) => { + activeUploadXhrsRef.current = new Set(xhrs) + }, + }) + const uploadId = String(result?.uploadId || '').trim() + const existingFilePath = String(result?.existingFilePath || '').trim() + if (!uploadId || !existingFilePath) throw new Error('分片上传结果不完整,请点击“重新上传”') + completedChunkUpload = { + uploadId, + existingFilePath, + originalFileName: selectedFile.name, + fileSize: selectedFile.size, + } + completedChunkUploadRef.current = completedChunkUpload + persistCompletedUpload(selectedFile, completedChunkUpload) + } + + if (uploadCancelledRef.current) throw new Error('上传已取消') const formData = new FormData() formData.set('title', title.trim()) formData.set('knowledgeType', knowledgeType) formData.set('brand', brand) formData.set('productCategoryName', productCategoryName) - formData.set('file', selectedFile) + if (completedChunkUpload) { + formData.set('uploadId', completedChunkUpload.uploadId) + formData.set('existingFilePath', completedChunkUpload.existingFilePath) + formData.set('originalFileName', completedChunkUpload.originalFileName) + formData.set('fileSize', String(completedChunkUpload.fileSize)) + } if (previewImage) { if (previewUsesServerFallback) { formData.set('previewImage', previewImage) @@ -217,35 +416,65 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return 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 || '品牌知识上传失败,请稍后重试') + setUploadStageMessage(completedChunkUpload ? '主文件已上传,正在保存知识信息…' : '正在保存知识信息…') + if (completedChunkUpload) setUploadProgress(100) + setUploadSpeed(null) + const controller = new AbortController() + submitAbortRef.current = controller + const response = await fetch('/api/department-dashboard/brand-knowledge', { + method: 'POST', + body: formData, + signal: controller.signal, + }) + await assertUploadResponse(response) + removePersistedCompletedUpload(selectedFile) + completedChunkUploadRef.current = null await onUploaded() onClose() } catch (error) { - setErrorMessage(error instanceof Error ? error.message : '品牌知识上传失败,请稍后重试') + const status = error && typeof error === 'object' && 'status' in error + ? Number((error as UploadError).status) + : 0 + if (selectedFile && (status === 403 || status === 409)) { + removePersistedCompletedUpload(selectedFile) + completedChunkUploadRef.current = null + } + setErrorMessage(thrownUploadErrorMessage(error, uploadCancelledRef.current)) + setUploadStageMessage('') } finally { + isUploadActiveRef.current = false + submitAbortRef.current = null + activeUploadXhrsRef.current.clear() setIsUploading(false) + setUploadSpeed(null) } } + const uploadSpeedLabel = formatUploadSpeed(uploadSpeed) + const statusMessage = errorMessage + || uploadStageMessage + || previewProgress + || '文件和预览图将独立保存,不会写入素材资产库' + return ( -
{ if (event.target === event.currentTarget) onClose() }}> +
{ if (event.target === event.currentTarget) requestClose() }}>
-

上传知识

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

+

上传知识

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

-
+
+ + {isUploading && uploadProgress !== null &&
主文件上传进度{uploadProgress}%{uploadSpeedLabel ? ` · ${uploadSpeedLabel}` : ''}
}

预览图片 (可选)

{previewImage && }
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]'}`}> { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ?
{previewUrl ? {`预览图:${previewImage.name}`} :

{previewImage.name}

{formatFileSize(previewImage.size)} · {previewSlices.length === 1 ? '单张预览' : previewSlices.length > 1 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}

:

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

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

}
-

知识文件

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

{selectedFile.name}

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

:

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

支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}

}
+

知识文件

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

{selectedFile.name}

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

:

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

支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}

}
-
+
) diff --git a/lib/__tests__/chunk-upload.test.ts b/lib/__tests__/chunk-upload.test.ts index 4c82562..d17d7bd 100644 --- a/lib/__tests__/chunk-upload.test.ts +++ b/lib/__tests__/chunk-upload.test.ts @@ -311,6 +311,49 @@ describe('chunkUpload', () => { await expect(getAllPendingUploads()).resolves.toEqual([]) }) + test('断点的上传用途不匹配时清理旧会话并重新初始化', async () => { + let initCount = 0 + ;(global.fetch as jest.Mock).mockImplementation((input: RequestInfo | URL) => { + if (String(input) === '/api/materials/upload/init') { + initCount += 1 + return Promise.resolve({ + ok: true, + json: async () => ({ uploadId: initCount === 1 ? 'upload-old-purpose' : 'upload-brand-purpose' }), + }) + } + return Promise.resolve({ ok: true, json: async () => ({ message: '清理成功' }) }) + }) + + const file = new File(['hello'], 'brand-guide.pdf', { + type: 'application/pdf', + lastModified: 123456, + }) + + MockXMLHttpRequest.defaultStatus = 500 + await expect(chunkUpload( + file, + { materialType: '任务-详情' }, + { chunkSize: 5, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 }, + )).rejects.toThrow('上传块 0 失败') + + MockXMLHttpRequest.defaultStatus = 413 + await expect(chunkUpload( + file, + { uploadPurpose: 'brand-knowledge' }, + { chunkSize: 5, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 }, + )).rejects.toThrow('上传分片超过入口服务器限制') + + const initCalls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => ( + String(url) === '/api/materials/upload/init' + )) + expect(initCalls).toHaveLength(2) + expect(JSON.parse(initCalls[1][1].body)).toMatchObject({ uploadPurpose: 'brand-knowledge' }) + expect(global.fetch).toHaveBeenCalledWith( + '/api/materials/upload/cleanup', + expect.objectContaining({ body: JSON.stringify({ uploadId: 'upload-old-purpose' }) }), + ) + }) + test('同一文件并发选择两次时拒绝第二条,避免共用 uploadId 覆盖分片', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: false, diff --git a/lib/__tests__/material-upload-safe-cleanup.test.ts b/lib/__tests__/material-upload-safe-cleanup.test.ts index 092b9ed..76bdaf4 100644 --- a/lib/__tests__/material-upload-safe-cleanup.test.ts +++ b/lib/__tests__/material-upload-safe-cleanup.test.ts @@ -6,6 +6,7 @@ import { readFile, rm, stat, + utimes, writeFile, } from 'fs/promises' import os from 'os' @@ -22,6 +23,16 @@ import { } from '@/lib/temp-cleanup' import { uploadSessions, type UploadSession } from '@/lib/upload-sessions' +const mockBrandKnowledgeFindFirst = jest.fn() + +jest.mock('@/lib/prisma', () => ({ + prisma: { + brandKnowledge: { + findFirst: mockBrandKnowledgeFindFirst, + }, + }, +})) + describe('safe material upload cleanup protocol', () => { let uploadRoot: string const uploadId = 'safe-cleanup-upload-1' @@ -31,6 +42,8 @@ describe('safe material upload cleanup protocol', () => { process.env.UPLOAD_DIR = uploadRoot process.env.USE_REDIS = 'false' uploadSessions.clear() + mockBrandKnowledgeFindFirst.mockReset() + mockBrandKnowledgeFindFirst.mockResolvedValue(null) }) afterEach(async () => { @@ -160,6 +173,34 @@ describe('safe material upload cleanup protocol', () => { }) }) + test('scheduled cleanup removes an abandoned completed brand-knowledge upload after 24 hours', async () => { + const abandonedId = 'abandoned-brand-knowledge-1' + const { tempDir, assembledPath } = await createAbandonedBrandKnowledgeUpload(abandonedId) + + const { runCleanup } = await import('@/lib/temp-cleanup') + const result = await runCleanup() + + expect(result.cleaned).toBe(1) + expect(mockBrandKnowledgeFindFirst).toHaveBeenCalled() + await expect(stat(tempDir)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(stat(assembledPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('scheduled cleanup retains an abandoned completed upload when database state is unavailable', async () => { + const abandonedId = 'abandoned-brand-knowledge-db-down' + const { tempDir, assembledPath } = await createAbandonedBrandKnowledgeUpload(abandonedId) + mockBrandKnowledgeFindFirst.mockRejectedValueOnce(new Error('database unavailable')) + const warningSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { runCleanup } = await import('@/lib/temp-cleanup') + const result = await runCleanup() + + expect(result).toMatchObject({ cleaned: 0, skippedUnavailable: 1 }) + await expect(stat(tempDir)).resolves.toMatchObject({}) + await expect(stat(assembledPath)).resolves.toMatchObject({}) + warningSpy.mockRestore() + }) + async function createUploadDirectory(id: string, withMerged = true) { const tempDir = path.join(uploadRoot, 'temp', id) const mergedPath = path.join(tempDir, 'merged-source.bin') @@ -193,4 +234,31 @@ describe('safe material upload cleanup protocol', () => { } uploadSessions.set(id, session) } + + async function createAbandonedBrandKnowledgeUpload(id: string) { + const createdAt = Date.now() - 25 * 60 * 60 * 1000 + const tempDir = path.join(uploadRoot, 'temp', id) + const assembledPath = path.join(uploadRoot, 'assembled', `${id}-source.bin`) + await mkdir(tempDir, { recursive: true }) + await mkdir(path.dirname(assembledPath), { recursive: true }) + await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({ + fileName: 'source.bin', + fileSize: 4, + totalChunks: 1, + chunkSize: 4, + userId: 'user-1', + uploadPurpose: 'brand-knowledge', + createdAt, + })) + await writeFile(path.join(tempDir, 'chunk-0'), 'data') + await writeFile(assembledPath, 'data') + await utimes(tempDir, new Date(createdAt), new Date(createdAt)) + const seal = await beginUploadCompletionSeal(id) + if (seal.state !== 'sealing') throw new Error('missing seal handle') + await completeUploadCompletionSeal(seal.handle) + const sealPath = path.join(uploadRoot, '.locks', 'completed-upload-sessions', `${id}.json`) + const storedSeal = JSON.parse(await readFile(sealPath, 'utf8')) + await writeFile(sealPath, JSON.stringify({ ...storedSeal, completedAt: createdAt })) + return { tempDir, assembledPath } + } }) diff --git a/lib/__tests__/multipart-route-coverage.test.ts b/lib/__tests__/multipart-route-coverage.test.ts index 2483f82..70a207c 100644 --- a/lib/__tests__/multipart-route-coverage.test.ts +++ b/lib/__tests__/multipart-route-coverage.test.ts @@ -60,7 +60,7 @@ describe('multipart routes are bounded before parsing', () => { const requiredFragments = [ 'location = /api/upload/preview {\n client_max_body_size 53m;', - 'location = /api/department-dashboard/brand-knowledge {\n client_max_body_size 1100m;', + 'location = /api/department-dashboard/brand-knowledge {\n client_max_body_size 53m;', 'location ~ "^/api/upload/s3(?:-original)?\\$" {\n client_max_body_size 103m;', 'location = /api/materials/update {\n client_max_body_size 11m;', 'location ~ "^/api/(?:user/avatar|system-settings/upload-logo)\\$" {\n client_max_body_size 7m;', @@ -71,7 +71,7 @@ describe('multipart routes are bounded before parsing', () => { expect(source).toContain(fragment) } - const boundedLocations = source.match(/client_max_body_size (?:7|11|53|103|106|131|1100)m;[\s\S]{0,180}?proxy_request_buffering off;/g) || [] + const boundedLocations = source.match(/client_max_body_size (?:7|11|53|103|106|131)m;[\s\S]{0,180}?proxy_request_buffering off;/g) || [] expect(boundedLocations.length).toBeGreaterThanOrEqual(7) }) }) diff --git a/lib/brand-knowledge-file-types.ts b/lib/brand-knowledge-file-types.ts index 7b29c89..33adc67 100644 --- a/lib/brand-knowledge-file-types.ts +++ b/lib/brand-knowledge-file-types.ts @@ -17,8 +17,9 @@ 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 +// The main file uses the shared chunk-upload pipeline. Finalization only carries +// metadata and at most 50 MiB of preview files plus multipart framing. +export const BRAND_KNOWLEDGE_MAX_REQUEST_BYTES = 52 * 1024 * 1024 export const BRAND_KNOWLEDGE_PREVIEW_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const export const BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT = BRAND_KNOWLEDGE_PREVIEW_IMAGE_EXTENSIONS.join(',') diff --git a/lib/chunk-upload.ts b/lib/chunk-upload.ts index 7efb388..4e32bdd 100644 --- a/lib/chunk-upload.ts +++ b/lib/chunk-upload.ts @@ -181,12 +181,13 @@ async function initUpload( fileSize: number, totalChunks: number, chunkSize: number, + uploadPurpose: string | undefined, requestHeaders: Record = {}, ): Promise { const response = await fetch('/api/materials/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json', ...requestHeaders }, - body: JSON.stringify({ fileName, fileSize, totalChunks, chunkSize }), + body: JSON.stringify({ fileName, fileSize, totalChunks, chunkSize, uploadPurpose }), }) if (!response.ok) { @@ -479,6 +480,12 @@ async function runChunkUpload( let state = await loadUploadState(fileId) if (state) { + const requestedUploadPurpose = typeof metadata?.uploadPurpose === 'string' + ? metadata.uploadPurpose.trim() + : '' + const storedUploadPurpose = typeof state.metadata?.uploadPurpose === 'string' + ? state.metadata.uploadPurpose.trim() + : '' const storedUploadedChunks = state.uploadedChunks instanceof Set ? state.uploadedChunks : null @@ -495,13 +502,14 @@ async function runChunkUpload( && index >= 0 && index < state!.totalChunks )) + const storedStateMatchesPurpose = storedUploadPurpose === requestedUploadPurpose - if (state.uploadId && storedStateMatchesFile && storedChunkSize) { + if (state.uploadId && storedStateMatchesFile && storedChunkSize && storedStateMatchesPurpose) { // 服务端会话的分片布局在 init 时已经固定。网络从公网切到内网(或反向) // 时仍沿用已保存的 10/20MiB 布局,不能拿新探测值切旧会话。 chunkSize = storedChunkSize totalChunks = state.totalChunks - } else if (state.uploadId || !storedStateMatchesFile || !storedChunkSize) { + } else if (state.uploadId || !storedStateMatchesFile || !storedChunkSize || !storedStateMatchesPurpose) { // 无法安全证明旧状态与当前文件/布局一致时,清理旧会话并重新初始化, // 避免把错误切片继续写进服务端既有会话。 if (state.uploadId) { @@ -544,7 +552,14 @@ async function runChunkUpload( // 初始化上传(如果需要) if (!state.uploadId) { try { - state.uploadId = await initUpload(file.name, file.size, totalChunks, chunkSize, requestHeaders) + state.uploadId = await initUpload( + file.name, + file.size, + totalChunks, + chunkSize, + typeof metadata?.uploadPurpose === 'string' ? metadata.uploadPurpose : undefined, + requestHeaders, + ) await saveUploadState(state) } catch (error) { if (!isRetryableUploadError(error)) { @@ -557,7 +572,14 @@ async function runChunkUpload( while (retries < maxRetries) { await new Promise(resolve => setTimeout(resolve, retryDelay)) try { - state.uploadId = await initUpload(file.name, file.size, totalChunks, chunkSize, requestHeaders) + state.uploadId = await initUpload( + file.name, + file.size, + totalChunks, + chunkSize, + typeof metadata?.uploadPurpose === 'string' ? metadata.uploadPurpose : undefined, + requestHeaders, + ) await saveUploadState(state) break } catch (e) { @@ -831,7 +853,14 @@ async function runChunkUpload( console.warn(`[ChunkUpload] ⚠️ 会话刷新失败(${refreshResult.error}),重新初始化上传...`) // 刷新失败,需要重新初始化 - state.uploadId = await initUpload(file.name, file.size, totalChunks, chunkSize, requestHeaders) + state.uploadId = await initUpload( + file.name, + file.size, + totalChunks, + chunkSize, + typeof metadata?.uploadPurpose === 'string' ? metadata.uploadPurpose : undefined, + requestHeaders, + ) state.uploadedChunks.clear() completedBytes = 0 await saveUploadState(state) diff --git a/lib/temp-cleanup.ts b/lib/temp-cleanup.ts index c669dcb..8abd472 100644 --- a/lib/temp-cleanup.ts +++ b/lib/temp-cleanup.ts @@ -11,6 +11,7 @@ import path from 'path' import { acquireMaterialUploadStorageLock, + acquireTaskMaterialUploadSessionLocks, type MaterialUploadStorageLockRelease, } from './material-upload-cross-process-lock' import { @@ -25,10 +26,12 @@ import { getUploadRootDir } from './upload-paths' import { uploadSessions, type UploadSession } from './upload-sessions' const MAX_AGE_MS = 8 * 60 * 60 * 1000 +const ABANDONED_BRAND_KNOWLEDGE_RETENTION_MS = 24 * 60 * 60 * 1000 const CLEANUP_INTERVAL_MS = 60 * 60 * 1000 const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ const REDIS_SESSION_KEY_PREFIX = 'upload:session:' const INTERNAL_CONSUMED_CLEANUP = Symbol('internal-consumed-material-upload-cleanup') +const INTERNAL_ABANDONED_BRAND_KNOWLEDGE_CLEANUP = Symbol('internal-abandoned-brand-knowledge-cleanup') let cleanupTimer: NodeJS.Timeout | null = null let isCleanupRunning = false @@ -41,6 +44,7 @@ export type SafeMaterialUploadCleanupStatus = | 'protected_completed' | 'protected_sealing' | 'not_consumed' + | 'not_abandoned_brand_knowledge' | 'owner_mismatch' | 'unavailable' @@ -64,7 +68,9 @@ export interface SafeMaterialUploadCleanupInput { interface InternalCleanupInput extends SafeMaterialUploadCleanupInput { intent: 'cancel' | 'sweep' - capability?: typeof INTERNAL_CONSUMED_CLEANUP + capability?: + | typeof INTERNAL_CONSUMED_CLEANUP + | typeof INTERNAL_ABANDONED_BRAND_KNOWLEDGE_CLEANUP } interface StoredSessionEvidence { @@ -73,6 +79,7 @@ interface StoredSessionEvidence { fileName: string fileSize: number | null createdAt: number | null + uploadPurpose: string | null } interface UploadDirectorySnapshot { @@ -84,6 +91,7 @@ interface UploadDirectorySnapshot { fileName: string fileSize: number createdAt: number + uploadPurpose: string | null } | null files: Array<{ name: string; size: number }> deletedFiles: number @@ -100,6 +108,7 @@ interface CleanupEvidence { hasValidMerged: boolean sealState: UploadSessionSealState | null assembledFilePath: string | null + uploadPurpose: string | null } /** @@ -196,11 +205,14 @@ export async function runCleanup(): Promise<{ continue } - const cleanup = await safelyCleanupMaterialUploadSession({ - uploadId, - intent: 'sweep', - minAgeMs: MAX_AGE_MS, - }) + const uploadPurpose = await readUploadPurposeCandidate(dirPath) + const cleanup = uploadPurpose === 'brand-knowledge' + ? await cleanupAbandonedBrandKnowledgeUploadSession(uploadId) + : await safelyCleanupMaterialUploadSession({ + uploadId, + intent: 'sweep', + minAgeMs: MAX_AGE_MS, + }) applyRunCleanupResult(result, cleanup) } catch (error) { result.failed += 1 @@ -231,6 +243,50 @@ export async function runCleanup(): Promise<{ } } +async function cleanupAbandonedBrandKnowledgeUploadSession( + uploadId: string, +): Promise { + let releaseUploadSessionLock: MaterialUploadStorageLockRelease | null = null + try { + // Keep the same lock order as brand-knowledge finalization: scoped uploadId, + // then the global storage lock acquired inside executeSafeCleanup. + releaseUploadSessionLock = await acquireTaskMaterialUploadSessionLocks([uploadId]) + const seal = await readUploadSessionSeal(uploadId) + if (seal?.state !== 'completed') { + return await executeSafeCleanup({ + uploadId, + intent: 'sweep', + minAgeMs: MAX_AGE_MS, + }) + } + const completedAt = Number(seal.completedAt) + const completedAgeMs = Date.now() - completedAt + if (!Number.isFinite(completedAt) || completedAt <= 0 || completedAgeMs < -5 * 60 * 1000) { + return unavailableResult( + '上传完成时间状态异常,已保留上传文件', + 'UPLOAD_CLEANUP_COMPLETED_AT_INVALID', + ) + } + if (completedAgeMs < ABANDONED_BRAND_KNOWLEDGE_RETENTION_MS) { + return skippedResult('skipped_young') + } + // Database availability is a fail-closed gate, but it does not need the + // global upload-storage lock. The scoped upload lock already prevents a + // concurrent brand-knowledge finalization for this uploadId. + await assertBrandKnowledgeDatabaseAvailable(uploadId) + return await executeSafeCleanup({ + uploadId, + intent: 'sweep', + minAgeMs: ABANDONED_BRAND_KNOWLEDGE_RETENTION_MS, + capability: INTERNAL_ABANDONED_BRAND_KNOWLEDGE_CLEANUP, + }) + } catch (error) { + return resultFromError(error) + } finally { + await releaseUploadSessionLock?.() + } +} + export function startCleanupScheduler(): void { if (cleanupTimer) { console.log('[TempCleanup] 定时任务已在运行') @@ -280,6 +336,7 @@ async function executeSafeCleanup( decision = decideCleanup(input, evidence) if (!decision) { const isConsumedCleanup = input.capability === INTERNAL_CONSUMED_CLEANUP + || input.capability === INTERNAL_ABANDONED_BRAND_KNOWLEDGE_CLEANUP if (!isConsumedCleanup) { const tombstone = await cancelUploadSessionSeal(uploadId) if (tombstone.state === 'completed') { @@ -363,6 +420,31 @@ function decideCleanup( } const isConsumedCleanup = input.capability === INTERNAL_CONSUMED_CLEANUP + const isAbandonedBrandKnowledgeCleanup = input.capability === INTERNAL_ABANDONED_BRAND_KNOWLEDGE_CLEANUP + if (isAbandonedBrandKnowledgeCleanup) { + if (evidence.uploadPurpose !== 'brand-knowledge') { + return skippedResult('not_abandoned_brand_knowledge') + } + if ( + evidence.sealState !== 'completed' + || evidence.ageMs === null + || evidence.ageMs < ABANDONED_BRAND_KNOWLEDGE_RETENTION_MS + ) { + if (evidence.sealState === 'completed') { + return skippedResult('skipped_young') + } + if (evidence.sealState === 'sealing') { + return protectedResult('protected_sealing') + } + return skippedResult( + 'not_consumed', + '未确认 completed 终态,已保留上传临时目录', + 'UPLOAD_NOT_CONFIRMED_COMPLETED', + 409, + ) + } + return null + } if (isConsumedCleanup) { if ( !evidence.directory.exists @@ -473,6 +555,21 @@ async function readCleanupEvidence(uploadId: string): Promise { ) } + const uploadPurposes = new Set() + if (directory.metadata?.uploadPurpose) { + uploadPurposes.add(directory.metadata.uploadPurpose) + } + sessions.forEach((session) => { + if (session.uploadPurpose) uploadPurposes.add(session.uploadPurpose) + }) + if (uploadPurposes.size > 1) { + throw new MaterialUploadRequestError( + '上传会话用途状态冲突,已保留数据', + 503, + 'UPLOAD_CLEANUP_SESSION_CONFLICT', + ) + } + const expectedFileSize = fileSizes.values().next().value as number | undefined const hasValidTempMerged = directory.files.some((file) => ( file.name.startsWith('merged-') @@ -494,6 +591,7 @@ async function readCleanupEvidence(uploadId: string): Promise { } const seal = await readUploadSessionSeal(uploadId) + const uploadPurpose = uploadPurposes.values().next().value as string | undefined return { directory, sessions, @@ -504,6 +602,7 @@ async function readCleanupEvidence(uploadId: string): Promise { hasValidMerged: hasValidTempMerged || assembledFilePath !== null, sealState: seal?.state ?? null, assembledFilePath, + uploadPurpose: uploadPurpose || null, } } @@ -586,6 +685,7 @@ async function readMetadataFailClosed(metadataPath: string): Promise<{ fileName: string fileSize: number createdAt: number + uploadPurpose: string | null }> { try { const parsed = JSON.parse(await readFile(metadataPath, 'utf8')) as Record @@ -595,6 +695,9 @@ async function readMetadataFailClosed(metadataPath: string): Promise<{ ) const fileSize = Number(parsed.fileSize) const createdAt = Number(parsed.createdAt) + const uploadPurpose = typeof parsed.uploadPurpose === 'string' + ? parsed.uploadPurpose.trim() + : '' if ( !userId || !fileName @@ -602,10 +705,11 @@ async function readMetadataFailClosed(metadataPath: string): Promise<{ || fileSize <= 0 || !Number.isFinite(createdAt) || createdAt <= 0 + || (uploadPurpose && uploadPurpose !== 'brand-knowledge') ) { throw new Error('meta.json 字段无效') } - return { userId, fileName, fileSize, createdAt } + return { userId, fileName, fileSize, createdAt, uploadPurpose: uploadPurpose || null } } catch (error) { throw new MaterialUploadRequestError( `无法确认上传目录元数据,已保留数据: ${error instanceof Error ? error.message : '未知错误'}`, @@ -653,6 +757,9 @@ function normalizeSessionEvidence( ) const fileSize = Number(session.fileSize) const createdAt = Number(session.createdAt) + const uploadPurpose = typeof session.uploadPurpose === 'string' + ? session.uploadPurpose.trim() + : '' if ( !userId || !fileName @@ -660,6 +767,7 @@ function normalizeSessionEvidence( || fileSize <= 0 || !Number.isFinite(createdAt) || createdAt <= 0 + || (uploadPurpose && uploadPurpose !== 'brand-knowledge') ) { throw new MaterialUploadRequestError( '上传会话内容异常,已保留数据', @@ -667,7 +775,39 @@ function normalizeSessionEvidence( 'UPLOAD_CLEANUP_SESSION_INVALID', ) } - return { source, userId, fileName, fileSize, createdAt } + return { source, userId, fileName, fileSize, createdAt, uploadPurpose: uploadPurpose || null } +} + +async function readUploadPurposeCandidate(dirPath: string): Promise { + try { + const metadataPath = resolveStrictChildPath(dirPath, 'meta.json') + if (!metadataPath) return null + const stats = await lstat(metadataPath) + if (stats.isSymbolicLink() || !stats.isFile() || stats.size > 16 * 1024) return null + const parsed = JSON.parse(await readFile(metadataPath, 'utf8')) as Record + return typeof parsed.uploadPurpose === 'string' ? parsed.uploadPurpose.trim() || null : null + } catch { + // The locked cleanup path will parse the same metadata fail-closed. + return null + } +} + +async function assertBrandKnowledgeDatabaseAvailable(uploadId: string): Promise { + try { + const { prisma } = await import('./prisma') + await prisma.brandKnowledge.findFirst({ + where: { + filePath: { startsWith: `uploads/brand-knowledge/source-${uploadId}.` }, + }, + select: { id: true }, + }) + } catch (error) { + throw new MaterialUploadRequestError( + `无法确认品牌知识建档状态,已保留上传文件: ${error instanceof Error ? error.message : '未知错误'}`, + 503, + 'UPLOAD_CLEANUP_DATABASE_UNAVAILABLE', + ) + } } async function inspectAssembledFile( @@ -747,6 +887,7 @@ function applyRunCleanupResult( case 'absent': case 'skipped_young': case 'not_consumed': + case 'not_abandoned_brand_knowledge': case 'owner_mismatch': break default: diff --git a/lib/upload-sessions.ts b/lib/upload-sessions.ts index 46cb2df..4028b7f 100644 --- a/lib/upload-sessions.ts +++ b/lib/upload-sessions.ts @@ -79,6 +79,7 @@ export interface UploadSession { _speedSampleAt?: number _speedSampleBytes?: number userId: string + uploadPurpose?: string } function parsePositiveIntEnv(name: string, fallback: number): number { @@ -214,6 +215,9 @@ async function getUploadSessionFromMetadata(uploadId: string): Promise { // 导出内存Map(用于向后兼容,但不推荐直接使用) export const uploadSessions = memorySessions - diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 7ed9b3c..d674f65 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -2008,11 +2008,10 @@ http { proxy_buffering off; } - # Brand knowledge accepts one 1 GiB document and a 50 MiB preview plus multipart framing. - # Keep this allowance scoped to the exact collection endpoint so list and - # upload requests work without widening unrelated dashboard routes. + # Brand knowledge finalization only carries metadata and a bounded preview; + # the 1 GiB source file uses the shared 20 MiB chunk upload routes below. location = /api/department-dashboard/brand-knowledge { - client_max_body_size 1100m; + client_max_body_size 53m; proxy_request_buffering off; proxy_pass http://app_backend; proxy_hide_header X-Material-Proxy-Target; diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index 7edb90a..c1a1631 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -109,7 +109,7 @@ test('brand knowledge upload stays bounded in the route and stable proxy', () => assert.match( deploySource, - /location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 1100m;\n\s+proxy_request_buffering off;/, + /location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 53m;\n\s+proxy_request_buffering off;/, ) const headerGuard = routeSource.indexOf('assertBoundedMultipartHeaders(') @@ -122,6 +122,9 @@ test('brand knowledge upload stays bounded in the route and stable proxy', () => assert.ok(parsedFiles > parseBody) assert.match(routeSource, /BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES/) assert.match(routeSource, /BRAND_KNOWLEDGE_MAX_REQUEST_BYTES/) + assert.match(routeSource, /resolveOwnedMergedUpload/) + assert.match(routeSource, /publishMergedUploadByHardLink/) + assert.doesNotMatch(routeSource, /formData\.get\('file'\)/) assert.match(routeSource, /import \{ isWebFileLike \} from '@\/lib\/web-file'/) assert.doesNotMatch(routeSource, /instanceof File/) assert.match(routeSource, /releaseRequestSlot\?\.\(\)/)