fix(intake): bypass production upload body limit
This commit is contained in:
parent
cddbae3ab6
commit
ea67e56480
@ -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'],
|
||||
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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 () =>
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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。`
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user