fix: support large brand knowledge uploads
This commit is contained in:
parent
ab37a99601
commit
83673baf70
@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string> = new Set(BRAND_KNOWLEDGE_TYPE_OPTION
|
||||
const ALLOWED_EXTENSIONS: ReadonlySet<string> = 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<ReturnType<typeof prepareBrandKnowledgePreview>> | null = null
|
||||
let preparedUploadedSlices: Awaited<ReturnType<typeof validateBrandKnowledgeUploadedPreviewSlices>> | 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,7 +426,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.brandKnowledge.create({
|
||||
let created
|
||||
persistence.outcome = 'not_committed'
|
||||
try {
|
||||
created = await prisma.brandKnowledge.create({
|
||||
data: {
|
||||
title,
|
||||
knowledgeType,
|
||||
@ -300,8 +437,8 @@ export async function POST(request: Request) {
|
||||
productCategoryName,
|
||||
fileName: safeOriginalName,
|
||||
filePath: storedPath,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
fileSize: BigInt(file.size),
|
||||
mimeType: String(lookupMimeType(safeOriginalName) || 'application/octet-stream'),
|
||||
fileSize: BigInt(declaredFileSize),
|
||||
previewImageName,
|
||||
previewImagePath: storedPreviewPaths[0] || null,
|
||||
previewImageSlices: [...storedPreviewPaths],
|
||||
@ -309,53 +446,53 @@ export async function POST(request: Request) {
|
||||
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 } },
|
||||
},
|
||||
select: responseSelect,
|
||||
})
|
||||
storedPath = null
|
||||
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)
|
||||
}
|
||||
if (persistence.outcome !== 'committed' && persistence.outcome !== 'unknown') {
|
||||
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 {
|
||||
// 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。
|
||||
}
|
||||
}
|
||||
if (publishedMainPath) await unlink(publishedMainPath).catch(() => undefined)
|
||||
await Promise.all(storedPreviewPaths.map(async (previewPath) => {
|
||||
try {
|
||||
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?.()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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(<BrandKnowledgeDashboard />)
|
||||
|
||||
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(<BrandKnowledgeDashboard />)
|
||||
|
||||
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(<BrandKnowledgeDashboard />)
|
||||
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(<BrandKnowledgeDashboard />)
|
||||
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(<BrandKnowledgeDashboard />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<PersistedCompletedChunkUpload>
|
||||
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<void>
|
||||
@ -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<string>(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<number | null>(null)
|
||||
const [uploadSpeed, setUploadSpeed] = useState<number | null>(null)
|
||||
const [uploadStageMessage, setUploadStageMessage] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@ -65,6 +175,28 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
|
||||
const previewDragDepthRef = useRef(0)
|
||||
const previewProcessingAbortRef = useRef<AbortController | null>(null)
|
||||
const previewProcessingVersionRef = useRef(0)
|
||||
const submitAbortRef = useRef<AbortController | null>(null)
|
||||
const activeUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set())
|
||||
const uploadCancelledRef = useRef(false)
|
||||
const isUploadActiveRef = useRef(false)
|
||||
const completedChunkUploadRef = useRef<CompletedChunkUpload | null>(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<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current += 1; setIsDragging(true) }
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) { dragDepthRef.current = 0; setIsDragging(false) } }
|
||||
const handlePreviewDragEnter = (event: DragEvent<HTMLDivElement>) => { 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#06192d]/45 p-4 sm:p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#06192d]/45 p-4 sm:p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) requestClose() }}>
|
||||
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="brand-knowledge-upload-title" aria-describedby="brand-knowledge-upload-status" className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_24px_56px_rgba(6,25,45,0.22)] sm:max-h-[calc(100dvh-3rem)]">
|
||||
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6"><div className="flex items-start justify-between gap-5"><div className="min-w-0"><h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。</p></div><button ref={closeButtonRef} type="button" onClick={onClose} aria-label="关闭上传知识窗口" title="关闭" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><X className="h-5 w-5" aria-hidden="true" /></button></div></header>
|
||||
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6"><div className="flex items-start justify-between gap-5"><div className="min-w-0"><h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。</p></div><button ref={closeButtonRef} type="button" onClick={requestClose} aria-label={isUploading ? '取消当前上传' : '关闭上传知识窗口'} title={isUploading ? '取消上传' : '关闭'} className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><X className="h-5 w-5" aria-hidden="true" /></button></div></header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-6 sm:px-8">
|
||||
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || previewProgress || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && !previewProgress && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB,长图会自动切片并连续展示。</p>}</div></div>
|
||||
<div id="brand-knowledge-upload-status" role={errorMessage ? 'alert' : 'status'} aria-live={errorMessage ? 'assertive' : 'polite'} className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div className="min-w-0"><p className="break-words font-semibold">{statusMessage}</p>{!errorMessage && !uploadStageMessage && !previewProgress && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB,长图会自动切片并连续展示。</p>}</div></div>
|
||||
|
||||
{isUploading && uploadProgress !== null && <div className="space-y-2" role="progressbar" aria-label="主文件上传进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={uploadProgress} aria-valuetext={`${uploadProgress}%${uploadSpeedLabel ? `,${uploadSpeedLabel}` : ''}`}><div className="flex items-center justify-between gap-4 text-sm text-[#4f6077]"><span>主文件上传进度</span><span className="shrink-0 tabular-nums">{uploadProgress}%{uploadSpeedLabel ? ` · ${uploadSpeedLabel}` : ''}</span></div><div className="h-2 overflow-hidden rounded-full bg-[#e2e8f0]"><div className="h-full rounded-full bg-[#247fd3] transition-[width] duration-200" style={{ width: `${uploadProgress}%` }} /></div></div>}
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label></div>
|
||||
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>
|
||||
|
||||
<section aria-labelledby="brand-knowledge-preview-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-preview-label" className="text-sm font-semibold text-[#101828]">预览图片 <span className="font-normal text-[#66758b]">(可选)</span></h3>{previewImage && <button type="button" onClick={removePreview} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除图片</button>}</div><div onDragEnter={handlePreviewDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); void setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={previewInputRef} type="file" aria-label="选择预览图片" accept={BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT} onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ? <div className="flex items-center gap-4"><div className="relative flex h-28 w-40 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#e2e8f0] bg-white">{previewUrl ? <Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-contain object-center" /> : <ImagePlus className="h-6 w-6 text-[#8a99ad]" aria-hidden="true" />}</div><div className="min-w-0"><p className="break-all text-sm font-semibold text-[#101828]">{previewImage.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(previewImage.size)} · {previewSlices.length === 1 ? '单张预览' : previewSlices.length > 1 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}</p><button type="button" onClick={() => previewInputRef.current?.click()} disabled={Boolean(previewProgress && previewSlices.length === 0 && !previewUsesServerFallback)} className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换图片</button></div></div> : <div className="flex flex-col items-center py-4 text-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><ImagePlus className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放预览图片到这里,或选择图片</p><p className="mt-1 text-sm text-[#66758b]">支持 JPG、PNG、WebP,单张不超过 50MB</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择图片</button></div>}</div></section>
|
||||
|
||||
<section aria-labelledby="brand-knowledge-file-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>{selectedFile && <button type="button" onClick={() => setSelectedFile(null)} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除文件</button>}</div><div onDragEnter={handleDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handleDragLeave} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); setFile(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={fileInputRef} id="brand-knowledge-file" aria-label="选择知识文件" type="file" accept={BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT} onChange={(event) => setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ? <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm"><FileText className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换文件</button></div> : <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><Upload className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p><p className="mt-1 max-w-xl text-sm text-[#66758b]">支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择文件</button></div>}</div></section>
|
||||
<section aria-labelledby="brand-knowledge-file-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>{selectedFile && <button type="button" onClick={removeFile} disabled={isUploading} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除文件</button>}</div><div onDragEnter={handleDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handleDragLeave} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); setFile(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={fileInputRef} id="brand-knowledge-file" aria-label="选择知识文件" type="file" accept={BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT} disabled={isUploading} onChange={(event) => setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ? <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm"><FileText className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p><button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换文件</button></div> : <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><Upload className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p><p className="mt-1 max-w-xl text-sm text-[#66758b]">支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}</p><button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择文件</button></div>}</div></section>
|
||||
</div>
|
||||
|
||||
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8"><button type="button" onClick={onClose} className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">取消</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim() || Boolean(previewImage && previewSlices.length === 0 && !previewUsesServerFallback)} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Upload className="h-4 w-4" aria-hidden="true" />{isUploading ? '上传中...' : previewImage && previewSlices.length === 0 && !previewUsesServerFallback ? '正在处理预览图' : '提交知识'}</button></footer>
|
||||
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8"><button type="button" onClick={isUploading ? cancelActiveUpload : onClose} className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">{isUploading ? '取消上传' : '取消'}</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim() || Boolean(previewImage && previewSlices.length === 0 && !previewUsesServerFallback)} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Upload className="h-4 w-4" aria-hidden="true" />{isUploading ? '上传中…' : previewImage && previewSlices.length === 0 && !previewUsesServerFallback ? '正在处理预览图' : errorMessage ? '重新上传' : '提交知识'}</button></footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(',')
|
||||
|
||||
@ -181,12 +181,13 @@ async function initUpload(
|
||||
fileSize: number,
|
||||
totalChunks: number,
|
||||
chunkSize: number,
|
||||
uploadPurpose: string | undefined,
|
||||
requestHeaders: Record<string, string> = {},
|
||||
): Promise<string> {
|
||||
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)
|
||||
|
||||
@ -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,7 +205,10 @@ export async function runCleanup(): Promise<{
|
||||
continue
|
||||
}
|
||||
|
||||
const cleanup = await safelyCleanupMaterialUploadSession({
|
||||
const uploadPurpose = await readUploadPurposeCandidate(dirPath)
|
||||
const cleanup = uploadPurpose === 'brand-knowledge'
|
||||
? await cleanupAbandonedBrandKnowledgeUploadSession(uploadId)
|
||||
: await safelyCleanupMaterialUploadSession({
|
||||
uploadId,
|
||||
intent: 'sweep',
|
||||
minAgeMs: MAX_AGE_MS,
|
||||
@ -231,6 +243,50 @@ export async function runCleanup(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAbandonedBrandKnowledgeUploadSession(
|
||||
uploadId: string,
|
||||
): Promise<SafeMaterialUploadCleanupResult> {
|
||||
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<CleanupEvidence> {
|
||||
)
|
||||
}
|
||||
|
||||
const uploadPurposes = new Set<string>()
|
||||
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<CleanupEvidence> {
|
||||
}
|
||||
|
||||
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<CleanupEvidence> {
|
||||
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<string, unknown>
|
||||
@ -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<string | null> {
|
||||
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<string, unknown>
|
||||
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<void> {
|
||||
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:
|
||||
|
||||
@ -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<UploadSes
|
||||
? normalizeSafeFileName(metadata.fileName)
|
||||
: null
|
||||
const userId = typeof metadata?.userId === 'string' ? metadata.userId : ''
|
||||
const uploadPurpose = typeof metadata?.uploadPurpose === 'string'
|
||||
? metadata.uploadPurpose.trim()
|
||||
: ''
|
||||
const ageMs = Date.now() - createdAt
|
||||
|
||||
if (
|
||||
@ -239,6 +243,7 @@ async function getUploadSessionFromMetadata(uploadId: string): Promise<UploadSes
|
||||
chunks: new Map(),
|
||||
createdAt,
|
||||
userId,
|
||||
...(uploadPurpose ? { uploadPurpose } : {}),
|
||||
}
|
||||
setMemorySession(uploadId, recoveredSession)
|
||||
return recoveredSession
|
||||
@ -786,4 +791,3 @@ export async function refreshUploadSession(uploadId: string): Promise<boolean> {
|
||||
|
||||
// 导出内存Map(用于向后兼容,但不推荐直接使用)
|
||||
export const uploadSessions = memorySessions
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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\?\.\(\)/)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user