fix(intake): bypass production upload body limit

This commit is contained in:
WuYongJie 2026-05-07 16:22:14 +08:00
parent cddbae3ab6
commit ea67e56480
8 changed files with 595 additions and 212 deletions

View File

@ -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'],

View File

@ -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 },
)
}

View File

@ -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<string, unknown>
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)
},

View File

@ -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 () =>

View File

@ -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<string, unknown>
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 },
)

View File

@ -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<string, unknown>
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<string, unknown>
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
}

View File

@ -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。`

View File

@ -8,6 +8,27 @@ type ApiEnvelope<T> = {
}
}
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<T>(response: Response): Promise<T> {
const payload = (await response.json().catch(() => null)) as ApiEnvelope<T> | null
@ -18,6 +39,14 @@ async function readApiData<T>(response: Response): Promise<T> {
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<string, unknown>,
) {
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<UploadSharedNonIntegratedFileResponse>(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<UploadDetachedNonIntegratedFileResponse>(response)
}
async function uploadSharedNonIntegratedFileInChunks(
productId: string,
file: File,
category: string,
fileId?: string,
metadata?: Record<string, unknown>,
) {
return uploadNonIntegratedFileInChunks<UploadSharedNonIntegratedFileResponse>({
productId,
file,
attach: true,
category,
fileId,
metadata,
})
}
async function uploadDetachedNonIntegratedFileInChunks(productId: string, file: File) {
return uploadNonIntegratedFileInChunks<UploadDetachedNonIntegratedFileResponse>({
productId,
file,
attach: false,
})
}
async function uploadNonIntegratedFileInChunks<T>(params: {
productId: string
file: File
attach: boolean
category?: string
fileId?: string
metadata?: Record<string, unknown>
}) {
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: {