From ea67e564805ae3eb7ea84b93ceb7f4e4a67c6de6 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 7 May 2026 16:22:14 +0800 Subject: [PATCH] fix(intake): bypass production upload body limit --- furniture-3d-system/next.config.js | 5 + .../non-integrated/[id]/upload/chunk/route.ts | 203 ++++++++++++++++++ .../non-integrated/[id]/upload/route.ts | 126 ++--------- .../non-integrated/upload/route.test.ts | 88 ++++++++ .../api/intake/non-integrated/upload/route.ts | 104 ++------- .../intake/non-integrated/uploadHelpers.ts | 127 +++++++++++ .../api/intake/non-integrated/uploadLimit.ts | 2 + .../intake/non-integrated/nonIntegratedApi.ts | 152 +++++++++++-- 8 files changed, 595 insertions(+), 212 deletions(-) create mode 100644 furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts create mode 100644 furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts diff --git a/furniture-3d-system/next.config.js b/furniture-3d-system/next.config.js index 86647a37..e7446e6c 100644 --- a/furniture-3d-system/next.config.js +++ b/furniture-3d-system/next.config.js @@ -85,6 +85,11 @@ const nextConfig = { // Turbopack配置(Next.js 16默认启用) turbopack: {}, + // Non-integrated uploads can include model ZIPs. Keep Next proxy body limit aligned with the app upload limit. + experimental: { + proxyClientMaxBodySize: 500 * 1024 * 1024, + }, + // 本地开发允许 localhost 和 127.0.0.1 复用同一套 dev 资源,避免 UI 改动只在其中一个地址生效 allowedDevOrigins: ['localhost', '127.0.0.1', '192.168.42.134'], diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts new file mode 100644 index 00000000..876e6061 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/chunk/route.ts @@ -0,0 +1,203 @@ +import { existsSync } from 'fs' +import { mkdir, readFile, rm, stat, writeFile } from 'fs/promises' +import os from 'os' +import path from 'path' +import { NextRequest } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeNonIntegratedProductService } from '@/server/services' +import { + readOptionalFormValue, + readUploadMetadata, + shouldAttachUploadFile, + storeNonIntegratedUploadFile, +} from '../../../uploadHelpers' +import { + NON_INTEGRATED_UPLOAD_CHUNK_MAX_BYTES, + NON_INTEGRATED_UPLOAD_MAX_BYTES, + buildNonIntegratedFileTooLargeMessage, +} from '../../../uploadLimit' + +type RouteContext = { + params: Promise<{ id: string }> +} + +function sanitizeUploadId(value: string) { + return value.trim().replace(/[^a-zA-Z0-9_-]/g, '') +} + +function parseSafeInteger(value: string | undefined) { + if (!value) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : null +} + +function getChunkSessionDirectory(productId: string, uploadId: string) { + return path.join( + os.tmpdir(), + 'furniture-3d-system-non-integrated-upload-chunks', + encodeURIComponent(productId), + uploadId, + ) +} + +async function readMergedChunks(sessionDirectory: string, totalChunks: number) { + const buffers: Buffer[] = [] + let totalBytes = 0 + + for (let index = 0; index < totalChunks; index += 1) { + const chunkPath = path.join(sessionDirectory, `${index}.part`) + if (!existsSync(chunkPath)) { + return null + } + + const chunk = await readFile(chunkPath) + totalBytes += chunk.length + + if (totalBytes > NON_INTEGRATED_UPLOAD_MAX_BYTES) { + throw new AppError( + ErrorCode.FILE_TOO_LARGE, + buildNonIntegratedFileTooLargeMessage(), + ) + } + + buffers.push(chunk) + } + + return Buffer.concat(buffers, totalBytes) +} + +export async function POST(request: NextRequest, context: RouteContext) { + return handleRequest( + request, + async ({ requestId, user }) => { + const { id } = await context.params + const productId = decodeURIComponent(id) + const formData = await request.formData() + const chunk = formData.get('chunk') + const rawUploadId = readOptionalFormValue(formData, 'uploadId') + const uploadId = rawUploadId ? sanitizeUploadId(rawUploadId) : '' + const chunkIndex = parseSafeInteger(readOptionalFormValue(formData, 'chunkIndex')) + const totalChunks = parseSafeInteger(readOptionalFormValue(formData, 'totalChunks')) + const totalSize = parseSafeInteger(readOptionalFormValue(formData, 'totalSize')) + const fileName = readOptionalFormValue(formData, 'fileName') + const mimeType = + readOptionalFormValue(formData, 'mimeType') || 'application/octet-stream' + const category = readOptionalFormValue(formData, 'category') + const fileId = readOptionalFormValue(formData, 'fileId') + const metadata = readUploadMetadata(formData) + const shouldAttach = shouldAttachUploadFile(formData) + + if (!(chunk instanceof File)) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要上传的文件分片', 400, requestId) + } + + if ( + !uploadId || + chunkIndex === null || + totalChunks === null || + totalSize === null || + !fileName || + chunkIndex < 0 || + totalChunks <= 0 || + chunkIndex >= totalChunks || + totalSize <= 0 + ) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '上传分片参数不完整', 400, requestId) + } + + if (chunk.size <= 0) { + return jsonError(ErrorCode.VALIDATION_FAILED, '上传分片不能为空', 400, requestId) + } + + if (chunk.size > NON_INTEGRATED_UPLOAD_CHUNK_MAX_BYTES) { + return jsonError(ErrorCode.FILE_TOO_LARGE, '上传分片过大,请重新上传', 400, requestId) + } + + if (totalSize > NON_INTEGRATED_UPLOAD_MAX_BYTES) { + return jsonError( + ErrorCode.FILE_TOO_LARGE, + buildNonIntegratedFileTooLargeMessage(), + 400, + requestId, + ) + } + + try { + await intakeNonIntegratedProductService.assertCanWrite(productId, user) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, requestId) + } + + throw error + } + + const sessionDirectory = getChunkSessionDirectory(productId, uploadId) + await mkdir(sessionDirectory, { recursive: true }) + + const chunkBytes = Buffer.from(await chunk.arrayBuffer()) + const chunkPath = path.join(sessionDirectory, `${chunkIndex}.part`) + await writeFile(chunkPath, chunkBytes) + + const savedChunks = await Promise.all( + Array.from({ length: totalChunks }, async (_unused, index) => { + const currentPath = path.join(sessionDirectory, `${index}.part`) + if (!existsSync(currentPath)) return false + const currentStat = await stat(currentPath) + return currentStat.size > 0 + }), + ) + const uploadedChunks = savedChunks.filter(Boolean).length + + if (uploadedChunks < totalChunks) { + return jsonSuccess( + { + completed: false, + uploadedChunks, + totalChunks, + }, + 200, + requestId, + ) + } + + const mergedBytes = await readMergedChunks(sessionDirectory, totalChunks) + if (!mergedBytes) { + return jsonError(ErrorCode.FILE_UPLOAD_FAILED, '上传分片不完整,请重新上传', 400, requestId) + } + + if (mergedBytes.length !== totalSize) { + await rm(sessionDirectory, { recursive: true, force: true }) + return jsonError(ErrorCode.FILE_UPLOAD_FAILED, '上传文件大小校验失败,请重新上传', 400, requestId) + } + + const responseData = await storeNonIntegratedUploadFile({ + bytes: mergedBytes, + originalFileName: fileName, + mimeType, + size: totalSize, + productId, + category, + fileId, + metadata, + shouldAttach, + user, + }) + + await rm(sessionDirectory, { recursive: true, force: true }) + + return jsonSuccess( + { + completed: true, + uploadedChunks, + totalChunks, + ...responseData, + }, + 201, + requestId, + ) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts index dad90dcc..07ec23d7 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/upload/route.ts @@ -1,21 +1,13 @@ -import { randomUUID } from 'crypto' -import { existsSync } from 'fs' -import { mkdir, writeFile } from 'fs/promises' -import path from 'path' import { NextRequest } from 'next/server' -import { - buildNonIntegratedAssetUrl, -} from '@/app/intake/non-integrated/nonIntegratedAssetUrl' -import { - getNonIntegratedAssetDirectory, -} from '@/app/intake/non-integrated/nonIntegratedAssetServer' -import type { - NonIntegratedProductRecord, - NonIntegratedUploadFile, -} from '@/app/intake/non-integrated/nonIntegratedStorage' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { intakeNonIntegratedProductService } from '@/server/services' +import { + readOptionalFormValue, + readUploadMetadata, + shouldAttachUploadFile, + storeNonIntegratedUploadFile, +} from '../../uploadHelpers' import { NON_INTEGRATED_UPLOAD_MAX_BYTES, buildNonIntegratedFileTooLargeMessage, @@ -25,51 +17,6 @@ type RouteContext = { params: Promise<{ id: string }> } -type UploadRouteResponse = { - file: NonIntegratedUploadFile | null - url: string - fileName: string - mimeType: string - size: number - storageKey: string - product?: NonIntegratedProductRecord -} - -function sanitizeExtension(fileName: string) { - const extension = path.extname(fileName).trim().toLowerCase() - if (!extension) return '' - return extension.replace(/[^a-z0-9.]/g, '') -} - -function readOptionalFormValue(formData: FormData, key: string) { - const value = formData.get(key) - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined -} - -function readUploadMetadata(formData: FormData) { - const rawMetadata = readOptionalFormValue(formData, 'metadata') - if (!rawMetadata) { - return {} - } - - try { - const parsed = JSON.parse(rawMetadata) as Record - return typeof parsed === 'object' && parsed !== null ? parsed : {} - } catch { - return {} - } -} - -function shouldAttachUploadFile(formData: FormData) { - const rawValue = readOptionalFormValue(formData, 'attach') - if (!rawValue) { - return true - } - - const normalized = rawValue.toLowerCase() - return normalized !== 'false' && normalized !== '0' && normalized !== 'no' -} - export async function POST(request: NextRequest, context: RouteContext) { return handleRequest( request, @@ -110,58 +57,19 @@ export async function POST(request: NextRequest, context: RouteContext) { throw error } - const uploadDir = getNonIntegratedAssetDirectory() - if (!existsSync(uploadDir)) { - await mkdir(uploadDir, { recursive: true }) - } - - const extension = sanitizeExtension(file.name) - const storedFileName = `${Date.now()}-${randomUUID()}${extension}` - const storedFilePath = path.join(uploadDir, storedFileName) const bytes = await file.arrayBuffer() - - await writeFile(storedFilePath, Buffer.from(bytes)) - - const url = buildNonIntegratedAssetUrl(storedFileName) - const mimeType = file.type || 'application/octet-stream' - - if (!shouldAttach) { - const responseData: UploadRouteResponse = { - file: null, - url, - fileName: file.name, - mimeType, - size: file.size, - storageKey: storedFileName, - } - - return jsonSuccess(responseData, 201, requestId) - } - - const attachedResult = await intakeNonIntegratedProductService.attachUploadFile( - productId, - { - fileId, - name: file.name, - category, - dataUrl: url, - mimeType, - size: file.size, - storageKey: storedFileName, - ...metadata, - }, - user, - ) - - const responseData: UploadRouteResponse = { - product: attachedResult.product, - file: attachedResult.file, - url, - fileName: file.name, - mimeType, + const responseData = await storeNonIntegratedUploadFile({ + bytes: Buffer.from(bytes), + originalFileName: file.name, + mimeType: file.type, size: file.size, - storageKey: storedFileName, - } + productId, + category, + fileId, + metadata, + shouldAttach, + user, + }) return jsonSuccess(responseData, 201, requestId) }, diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts index 974eafa6..b4f689dd 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.test.ts @@ -57,11 +57,13 @@ jest.mock('@/app/intake/non-integrated/nonIntegratedAssetServer', () => ({ jest.mock('@/server/services', () => ({ intakeNonIntegratedProductService: { + assertCanWrite: jest.fn(), attachUploadFile: (...args: unknown[]) => mockAttachUploadFile(...args), }, })) const { POST } = require('./route') +const { POST: POST_PRODUCT_UPLOAD_CHUNK } = require('../[id]/upload/chunk/route') const { GET } = require('../assets/[...assetPath]/route') function createFormData(file: File) { @@ -267,6 +269,92 @@ describe('POST /api/intake/non-integrated/upload', () => { ) }) + it('merges chunked uploads before attaching the file to the product', async () => { + const fileBytes = new TextEncoder().encode('chunk-one/chunk-two') + mockAttachUploadFile.mockResolvedValueOnce({ + product: { id: 'product-1' }, + file: { + id: 'file-1', + name: 'model.zip', + category: '产品模型', + dataUrl: '/api/intake/non-integrated/assets/model.zip', + mimeType: 'application/zip', + }, + }) + + async function uploadChunk(chunkIndex: number, chunkBytes: Uint8Array) { + const formData = new FormData() + formData.append( + 'chunk', + createUploadFile(chunkBytes, 'model.zip', 'application/zip'), + ) + formData.append('uploadId', 'upload-test-1') + formData.append('chunkIndex', String(chunkIndex)) + formData.append('totalChunks', '2') + formData.append('totalSize', String(fileBytes.length)) + formData.append('fileName', 'model.zip') + formData.append('mimeType', 'application/zip') + formData.append('attach', 'true') + formData.append('category', '产品模型') + formData.append('fileId', 'file-1') + + return POST_PRODUCT_UPLOAD_CHUNK( + { + formData: async () => formData, + } as any, + { + params: Promise.resolve({ id: 'product-1' }), + }, + ) + } + + const firstResponse = await uploadChunk(0, fileBytes.slice(0, 10)) + await expect(firstResponse.json()).resolves.toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + completed: false, + uploadedChunks: 1, + totalChunks: 2, + }), + }), + ) + + const completedResponse = await uploadChunk(1, fileBytes.slice(10)) + const completedPayload = await completedResponse.json() + + expect(completedResponse.status).toBe(201) + expect(completedPayload).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + completed: true, + fileName: 'model.zip', + mimeType: 'application/zip', + size: fileBytes.length, + storageKey: expect.stringMatching(/\.zip$/), + }), + }), + ) + expect(mockAttachUploadFile).toHaveBeenCalledWith( + 'product-1', + expect.objectContaining({ + fileId: 'file-1', + name: 'model.zip', + category: '产品模型', + mimeType: 'application/zip', + size: fileBytes.length, + storageKey: expect.stringMatching(/\.zip$/), + }), + expect.objectContaining({ id: 'admin-1' }), + ) + + const storedBytes = await readFile( + path.join(assetDirectory, completedPayload.data.storageKey), + ) + expect(Buffer.from(storedBytes).toString('utf8')).toBe('chunk-one/chunk-two') + }) + it('rejects files above the configured intake upload limit', async () => { const uploadResponse = await POST({ formData: async () => diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts index 5239dcdf..51093404 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts @@ -1,43 +1,17 @@ -import { existsSync } from 'fs' -import { mkdir, writeFile } from 'fs/promises' -import path from 'path' -import { randomUUID } from 'crypto' import { NextRequest } from 'next/server' -import { buildNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl' -import { getNonIntegratedAssetDirectory } from '@/app/intake/non-integrated/nonIntegratedAssetServer' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { intakeNonIntegratedProductService } from '@/server/services' +import { + readOptionalFormValue, + readUploadMetadata, + storeNonIntegratedUploadFile, +} from '../uploadHelpers' import { NON_INTEGRATED_UPLOAD_MAX_BYTES, buildNonIntegratedFileTooLargeMessage, } from '../uploadLimit' -function sanitizeExtension(fileName: string) { - const extension = path.extname(fileName).trim().toLowerCase() - if (!extension) return '' - return extension.replace(/[^a-z0-9.]/g, '') -} - -function readOptionalFormValue(formData: FormData, key: string) { - const value = formData.get(key) - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined -} - -function readUploadMetadata(formData: FormData) { - const rawMetadata = readOptionalFormValue(formData, 'metadata') - if (!rawMetadata) { - return {} - } - - try { - const parsed = JSON.parse(rawMetadata) as Record - return typeof parsed === 'object' && parsed !== null ? parsed : {} - } catch { - return {} - } -} - export async function POST(request: NextRequest) { return handleRequest( request, @@ -78,63 +52,21 @@ export async function POST(request: NextRequest) { } } - const uploadDir = getNonIntegratedAssetDirectory() - if (!existsSync(uploadDir)) { - await mkdir(uploadDir, { recursive: true }) - } - - const extension = sanitizeExtension(file.name) - const storedFileName = `${Date.now()}-${randomUUID()}${extension}` - const storedFilePath = path.join(uploadDir, storedFileName) const bytes = await file.arrayBuffer() + const responseData = await storeNonIntegratedUploadFile({ + bytes: Buffer.from(bytes), + originalFileName: file.name, + mimeType: file.type, + size: file.size, + productId, + category, + fileId, + metadata, + shouldAttach: Boolean(productId), + user, + }) - await writeFile(storedFilePath, Buffer.from(bytes)) - - const url = buildNonIntegratedAssetUrl(storedFileName) - const mimeType = file.type || 'application/octet-stream' - - if (productId) { - const attachedResult = await intakeNonIntegratedProductService.attachUploadFile( - productId, - { - fileId, - name: file.name, - category, - dataUrl: url, - mimeType, - size: file.size, - storageKey: storedFileName, - ...metadata, - }, - user, - ) - - return jsonSuccess( - { - product: attachedResult.product, - file: attachedResult.file, - url, - fileName: file.name, - mimeType, - size: file.size, - storageKey: storedFileName, - }, - 201, - requestId, - ) - } - - return jsonSuccess( - { - url, - fileName: file.name, - mimeType, - size: file.size, - storageKey: storedFileName, - }, - 201, - requestId, - ) + return jsonSuccess(responseData, 201, requestId) }, { requireAuth: true }, ) diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts b/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts new file mode 100644 index 00000000..a7b41cb3 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/uploadHelpers.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' +import { randomUUID } from 'crypto' +import { buildNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl' +import { getNonIntegratedAssetDirectory } from '@/app/intake/non-integrated/nonIntegratedAssetServer' +import type { + NonIntegratedProductRecord, + NonIntegratedUploadFile, +} from '@/app/intake/non-integrated/nonIntegratedStorage' +import { intakeNonIntegratedProductService } from '@/server/services' +import type { AuthUser } from '@/server/core/types' + +export type NonIntegratedUploadRouteResponse = { + file: NonIntegratedUploadFile | null + url: string + fileName: string + mimeType: string + size: number + storageKey: string + product?: NonIntegratedProductRecord +} + +export function sanitizeUploadExtension(fileName: string) { + const extension = path.extname(fileName).trim().toLowerCase() + if (!extension) return '' + return extension.replace(/[^a-z0-9.]/g, '') +} + +export function readOptionalFormValue(formData: FormData, key: string) { + const value = formData.get(key) + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +export function readUploadMetadata(formData: FormData) { + const rawMetadata = readOptionalFormValue(formData, 'metadata') + if (!rawMetadata) { + return {} + } + + try { + const parsed = JSON.parse(rawMetadata) as Record + return typeof parsed === 'object' && parsed !== null ? parsed : {} + } catch { + return {} + } +} + +export function shouldAttachUploadFile(formData: FormData) { + const rawValue = readOptionalFormValue(formData, 'attach') + if (!rawValue) { + return true + } + + const normalized = rawValue.toLowerCase() + return normalized !== 'false' && normalized !== '0' && normalized !== 'no' +} + +export async function storeNonIntegratedUploadFile(input: { + bytes: Buffer + originalFileName: string + mimeType: string + size?: number + productId?: string + category?: string + fileId?: string + metadata?: Record + shouldAttach?: boolean + user: AuthUser +}) { + const uploadDir = getNonIntegratedAssetDirectory() + if (!existsSync(uploadDir)) { + await mkdir(uploadDir, { recursive: true }) + } + + const extension = sanitizeUploadExtension(input.originalFileName) + const storedFileName = `${Date.now()}-${randomUUID()}${extension}` + const storedFilePath = path.join(uploadDir, storedFileName) + + await writeFile(storedFilePath, input.bytes) + + const url = buildNonIntegratedAssetUrl(storedFileName) + const mimeType = input.mimeType || 'application/octet-stream' + const fileSize = + typeof input.size === 'number' && Number.isFinite(input.size) + ? input.size + : input.bytes.length + const responseBase = { + url, + fileName: input.originalFileName, + mimeType, + size: fileSize, + storageKey: storedFileName, + } + + if (!input.productId || input.shouldAttach === false) { + const responseData: NonIntegratedUploadRouteResponse = { + file: null, + ...responseBase, + } + + return responseData + } + + const attachedResult = await intakeNonIntegratedProductService.attachUploadFile( + input.productId, + { + fileId: input.fileId, + name: input.originalFileName, + category: input.category, + dataUrl: url, + mimeType, + size: fileSize, + storageKey: storedFileName, + ...(input.metadata ?? {}), + }, + input.user, + ) + + const responseData: NonIntegratedUploadRouteResponse = { + product: attachedResult.product, + file: attachedResult.file, + ...responseBase, + } + + return responseData +} diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/uploadLimit.ts b/furniture-3d-system/src/app/api/intake/non-integrated/uploadLimit.ts index fab0aead..6aebad55 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/uploadLimit.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/uploadLimit.ts @@ -14,6 +14,8 @@ function readConfiguredUploadLimitMb() { export const NON_INTEGRATED_UPLOAD_MAX_MB = readConfiguredUploadLimitMb() export const NON_INTEGRATED_UPLOAD_MAX_BYTES = NON_INTEGRATED_UPLOAD_MAX_MB * 1024 * 1024 +export const NON_INTEGRATED_UPLOAD_CHUNK_MAX_BYTES = 512 * 1024 +export const NON_INTEGRATED_UPLOAD_RECOMMENDED_CHUNK_BYTES = 256 * 1024 export function buildNonIntegratedFileTooLargeMessage() { return `文件过大,最大允许 ${NON_INTEGRATED_UPLOAD_MAX_MB}MB。` diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts index 4d92f95a..4743af2e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts @@ -8,6 +8,27 @@ type ApiEnvelope = { } } +const NON_INTEGRATED_UPLOAD_CHUNK_BYTES = 256 * 1024 + +type UploadSharedNonIntegratedFileResponse = { + product: NonIntegratedProductRecord + file: NonIntegratedProductRecord['uploadFiles'][number] + url: string + fileName: string + mimeType: string + size?: number + storageKey?: string +} + +type UploadDetachedNonIntegratedFileResponse = { + file: null + url: string + fileName: string + mimeType: string + size?: number + storageKey?: string +} + async function readApiData(response: Response): Promise { const payload = (await response.json().catch(() => null)) as ApiEnvelope | null @@ -18,6 +39,14 @@ async function readApiData(response: Response): Promise { return payload.data } +function createUploadId() { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + + return `upload-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + export async function listSharedNonIntegratedProducts() { const response = await fetch('/api/intake/non-integrated', { method: 'GET', @@ -66,6 +95,10 @@ export async function uploadSharedNonIntegratedFile( fileId?: string, metadata?: Record, ) { + if (file.size > NON_INTEGRATED_UPLOAD_CHUNK_BYTES) { + return uploadSharedNonIntegratedFileInChunks(productId, file, category, fileId, metadata) + } + const formData = new FormData() formData.append('file', file) formData.append('category', category) @@ -87,18 +120,14 @@ export async function uploadSharedNonIntegratedFile( }, ) - return readApiData<{ - product: NonIntegratedProductRecord - file: NonIntegratedProductRecord['uploadFiles'][number] - url: string - fileName: string - mimeType: string - size?: number - storageKey?: string - }>(response) + return readApiData(response) } export async function uploadDetachedNonIntegratedFile(productId: string, file: File) { + if (file.size > NON_INTEGRATED_UPLOAD_CHUNK_BYTES) { + return uploadDetachedNonIntegratedFileInChunks(productId, file) + } + const formData = new FormData() formData.append('file', file) formData.append('attach', 'false') @@ -111,14 +140,103 @@ export async function uploadDetachedNonIntegratedFile(productId: string, file: F }, ) - return readApiData<{ - file: null - url: string - fileName: string - mimeType: string - size?: number - storageKey?: string - }>(response) + return readApiData(response) +} + +async function uploadSharedNonIntegratedFileInChunks( + productId: string, + file: File, + category: string, + fileId?: string, + metadata?: Record, +) { + return uploadNonIntegratedFileInChunks({ + productId, + file, + attach: true, + category, + fileId, + metadata, + }) +} + +async function uploadDetachedNonIntegratedFileInChunks(productId: string, file: File) { + return uploadNonIntegratedFileInChunks({ + productId, + file, + attach: false, + }) +} + +async function uploadNonIntegratedFileInChunks(params: { + productId: string + file: File + attach: boolean + category?: string + fileId?: string + metadata?: Record +}) { + const uploadId = createUploadId() + const totalChunks = Math.max( + 1, + Math.ceil(params.file.size / NON_INTEGRATED_UPLOAD_CHUNK_BYTES), + ) + let completedPayload: T | null = null + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { + const start = chunkIndex * NON_INTEGRATED_UPLOAD_CHUNK_BYTES + const end = Math.min(start + NON_INTEGRATED_UPLOAD_CHUNK_BYTES, params.file.size) + const chunk = params.file.slice( + start, + end, + params.file.type || 'application/octet-stream', + ) + const formData = new FormData() + + formData.append('chunk', chunk, params.file.name) + formData.append('uploadId', uploadId) + formData.append('chunkIndex', String(chunkIndex)) + formData.append('totalChunks', String(totalChunks)) + formData.append('totalSize', String(params.file.size)) + formData.append('fileName', params.file.name) + formData.append('mimeType', params.file.type || 'application/octet-stream') + formData.append('attach', params.attach ? 'true' : 'false') + + if (params.category) { + formData.append('category', params.category) + } + + if (params.fileId) { + formData.append('fileId', params.fileId) + } + + if (params.metadata && Object.keys(params.metadata).length > 0) { + formData.append('metadata', JSON.stringify(params.metadata)) + } + + const response = await fetch( + `/api/intake/non-integrated/${encodeURIComponent(params.productId)}/upload/chunk`, + { + method: 'POST', + body: formData, + }, + ) + const payload = await readApiData< + | ({ completed: false; uploadedChunks: number; totalChunks: number }) + | ({ completed: true; uploadedChunks: number; totalChunks: number } & T) + >(response) + + if (payload.completed) { + const { completed: _completed, uploadedChunks: _uploadedChunks, totalChunks: _totalChunks, ...rest } = payload + completedPayload = rest as T + } + } + + if (!completedPayload) { + throw new Error('文件分片上传未完成,请重试。') + } + + return completedPayload } export async function previewSharedNonIntegratedPresentation(params: {