chenpei 4712175cb0 feat(素材链路): 发布白底图双轨与AI生图回传能力
- 完成白底图 WebP 双轨接口、上传链路与预览缓存改造
- 新增 AI 生图平台成品回传接口、任务状态与表单规则
- 补齐 AI 生成标签、任务详情补全、相关测试与对接文档
2026-03-25 10:28:02 +08:00

230 lines
7.6 KiB
TypeScript

import { once } from 'events'
import { createWriteStream } from 'fs'
import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
interface ChunkRequestPayload {
uploadId: string
chunkIndex: number
fileName: string
chunkSizeHint: number
mode: 'raw' | 'multipart'
chunkFile?: File | null
}
export async function POST(req: NextRequest) {
const requestStartTime = Date.now()
console.log(`[UploadChunk] 📥 收到请求 at ${new Date().toISOString()}`)
try {
const authStartedAt = Date.now()
const session = await getServerSession(authOptions)
const authTime = Date.now() - authStartedAt
console.log(`[UploadChunk] 🔐 认证完成: ${authTime}ms`)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const payloadStartedAt = Date.now()
const payload = await resolveChunkPayload(req)
const payloadResolveTime = Date.now() - payloadStartedAt
console.log(
`[UploadChunk] 📦 请求参数解析完成: ${payloadResolveTime}ms, mode=${payload.mode}, chunkIndex=${payload.chunkIndex}`,
)
if (!payload.uploadId || Number.isNaN(payload.chunkIndex)) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
}
const sessionStartedAt = Date.now()
const sessionData = await getUploadSession(payload.uploadId)
const getSessionTime = Date.now() - sessionStartedAt
console.log(`[UploadChunk] 🔍 获取会话完成: ${getSessionTime}ms, 会话存在: ${!!sessionData}`)
if (!sessionData) {
console.log(`[UploadChunk] ❌ 会话不存在: ${payload.uploadId}`)
return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 })
}
if (sessionData.userId !== (session.user as any).id) {
return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 })
}
if (payload.chunkIndex < 0 || payload.chunkIndex >= sessionData.totalChunks) {
console.error(
`[UploadChunk] ❌ chunkIndex超出范围: ${payload.chunkIndex}, 总数: ${sessionData.totalChunks}`,
)
return NextResponse.json(
{ error: `chunkIndex超出范围: ${payload.chunkIndex}/${sessionData.totalChunks}` },
{ status: 400 },
)
}
const wasChunkAlreadyRecorded = sessionData.chunks.has(payload.chunkIndex)
const nextUploadedChunkCount = wasChunkAlreadyRecorded
? sessionData.chunks.size
: sessionData.chunks.size + 1
const mkdirStartedAt = Date.now()
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', payload.uploadId)
await mkdir(tempDir, { recursive: true })
const mkdirTime = Date.now() - mkdirStartedAt
const chunkFilePath = path.join(tempDir, `chunk-${payload.chunkIndex}`)
const writeStartedAt = Date.now()
const writtenBytes =
payload.mode === 'raw'
? await writeRawRequestBodyToFile(req, chunkFilePath)
: await writeMultipartChunkToFile(payload.chunkFile, chunkFilePath)
const writeTime = Date.now() - writeStartedAt
const statsStartedAt = Date.now()
await updateChunkWithStats(payload.uploadId, payload.chunkIndex, chunkFilePath, writtenBytes)
const updateChunkTime = Date.now() - statsStartedAt
const totalTime = Date.now() - requestStartTime
console.log(`[UploadChunk] ⏱️ Chunk ${payload.chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, {
认证: `${authTime}ms (${percent(authTime, totalTime)}%)`,
请求参数解析: `${payloadResolveTime}ms (${percent(payloadResolveTime, totalTime)}%)`,
获取会话: `${getSessionTime}ms (${percent(getSessionTime, totalTime)}%)`,
创建目录: `${mkdirTime}ms (${percent(mkdirTime, totalTime)}%)`,
流式写盘: `${writeTime}ms (${percent(writeTime, totalTime)}%)`,
更新会话: `${updateChunkTime}ms (${percent(updateChunkTime, totalTime)}%)`,
大小: `${(writtenBytes / 1024 / 1024).toFixed(2)}MB`,
模式: payload.mode,
})
if (writeTime / totalTime > 0.8) {
console.log(
`[UploadChunk] ⚠️ 流式写盘阶段占比 ${percent(writeTime, totalTime)}%,当前主要瓶颈仍在网络接收`,
)
}
return NextResponse.json({
success: true,
uploadedChunks: nextUploadedChunkCount,
totalChunks: sessionData.totalChunks,
serverProcessTime: totalTime,
receivedBytes: writtenBytes,
mode: payload.mode,
})
} catch (error) {
console.error('[UploadChunk] Error:', error)
return NextResponse.json({ error: '上传块失败' }, { status: 500 })
}
}
async function resolveChunkPayload(req: NextRequest): Promise<ChunkRequestPayload> {
const contentType = (req.headers.get('content-type') || '').toLowerCase()
if (!contentType.includes('multipart/form-data')) {
const uploadId = (req.headers.get('x-upload-id') || req.nextUrl.searchParams.get('uploadId') || '').trim()
const chunkIndexRaw =
req.headers.get('x-chunk-index') || req.nextUrl.searchParams.get('chunkIndex') || ''
const fileNameRaw =
req.headers.get('x-file-name') || req.nextUrl.searchParams.get('fileName') || ''
const chunkSizeHint = Number(
req.headers.get('x-chunk-size') || req.headers.get('content-length') || 0,
)
return {
uploadId,
chunkIndex: Number.parseInt(chunkIndexRaw, 10),
fileName: safeDecodeURIComponent(fileNameRaw),
chunkSizeHint: Number.isFinite(chunkSizeHint) ? chunkSizeHint : 0,
mode: 'raw',
chunkFile: null,
}
}
const formData = await req.formData()
const uploadId = (formData.get('uploadId') as string) || ''
const chunkIndex = Number.parseInt((formData.get('chunkIndex') as string) || '', 10)
const fileName = (formData.get('fileName') as string) || ''
const chunk = formData.get('chunk') as File | null
return {
uploadId: uploadId.trim(),
chunkIndex,
fileName,
chunkSizeHint: Number(chunk?.size || 0),
mode: 'multipart',
chunkFile: chunk,
}
}
async function writeRawRequestBodyToFile(req: NextRequest, chunkFilePath: string): Promise<number> {
if (!req.body) {
throw new Error('请求体为空')
}
const reader = req.body.getReader()
const fileStream = createWriteStream(chunkFilePath)
let writtenBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
if (!value || value.length === 0) {
continue
}
writtenBytes += value.length
if (!fileStream.write(Buffer.from(value))) {
await once(fileStream, 'drain')
}
}
} catch (error) {
fileStream.destroy()
throw error
} finally {
reader.releaseLock()
}
fileStream.end()
await once(fileStream, 'close')
return writtenBytes
}
async function writeMultipartChunkToFile(chunk: File | null | undefined, chunkFilePath: string): Promise<number> {
if (!chunk) {
throw new Error('缺少 chunk 文件')
}
const buffer = Buffer.from(await chunk.arrayBuffer())
await writeFile(chunkFilePath, buffer)
return buffer.length
}
function safeDecodeURIComponent(value: string): string {
if (!value) {
return ''
}
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function percent(part: number, total: number): string {
if (total <= 0) {
return '0.0'
}
return ((part / total) * 100).toFixed(1)
}