205 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 任务-详情上传完成接口(改造版)
* POST - 打包文件、生成预览图、保存资产信息
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
parsePublicProductPackageAssets,
} from '@/lib/product-package-completion'
import {
createTaskDetailUploadJob,
getLatestTaskDetailUploadJobStatus,
TaskDetailUploadQueueCapacityError,
} from '@/lib/task-detail-upload-jobs'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 60
interface CompleteRequestBody {
materialId: string
taskNo?: string
dingtalkApprovalNo?: string
noBarcodes?: boolean
allowServerPreviewFallback?: boolean
assets: unknown
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 },
)
}
const body: CompleteRequestBody = await request.json()
let assets
try {
assets = parsePublicProductPackageAssets(body.assets)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : '资产参数无效',
},
{ status: 400 },
)
}
const material = await prisma.material.findUnique({
where: { id: body.materialId },
select: {
id: true,
userId: true,
materialType: true,
taskDetailRequesterUserId: true,
taskDetailSourceType: true,
zipFilePath: true,
_count: { select: { assets: true } },
},
})
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 },
)
}
if (material.userId !== (session.user as any).id) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 },
)
}
if (material.materialType !== '任务-详情') {
return NextResponse.json(
{ success: false, error: '当前素材不是任务-详情,不能使用此完成接口' },
{ status: 400 },
)
}
if (!material.taskDetailRequesterUserId) {
return NextResponse.json(
{ success: false, error: '当前素材缺少需求运营人员,不能完成上传' },
{ status: 400 },
)
}
if (!normalizeTaskDetailSourceType(material.taskDetailSourceType)) {
return NextResponse.json(
{ success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' },
{ status: 400 },
)
}
// 先恢复已有任务的状态,再检查素材是否已有持久化结果。首次 202 响应若在
// 网络中丢失,后台可能已经完成;此时重试必须返回原 job,而不是误报 409。
const existingJob = await getLatestTaskDetailUploadJobStatus(
body.materialId,
(session.user as any).id,
)
if (existingJob && ['queued', 'processing', 'completed'].includes(existingJob.status)) {
return NextResponse.json({
success: true,
data: {
jobId: existingJob.jobId,
materialId: existingJob.materialId,
status: existingJob.status,
stage: existingJob.stage,
progress: existingJob.progress,
message: existingJob.status === 'completed'
? '后台处理已完成'
: '文件已接收,正在后台处理',
},
}, { status: 202 })
}
// 没有可复用任务时,初始化素材的 zip 路径和资产必须为空,防止再次入队
// 覆盖既有文件或产生重复资产。
if (material.zipFilePath || material._count?.assets > 0) {
return NextResponse.json(
{ success: false, error: '该素材已有后台处理结果,请刷新素材状态后再操作' },
{ status: 409 },
)
}
const imageAssets = [
...(assets.main || []),
...(assets.sku || []),
...(assets.detail || []),
]
if (imageAssets.length === 0) {
return NextResponse.json(
{ success: false, error: '请至少上传一张图片' },
{ status: 400 },
)
}
const missingClientPreviewCount = imageAssets.filter(
(asset) => !Array.isArray(asset.previewAssets) || asset.previewAssets.length === 0,
).length
if (missingClientPreviewCount > 0) {
if (body.allowServerPreviewFallback !== true) {
return NextResponse.json(
{ success: false, error: '客户端预览图缺失,请重新处理图片后再提交' },
{ status: 400 },
)
}
// 浏览器预处理属于性能优化,不应成为提交门槛。Edge 的 worker、图片编解码
// 或超大 EXIF 头部出现兼容性问题时,保留原图并交给后台唯一 worker 生成预览;
// 该 worker 仍受像素、文件大小和全局并发限制保护。
console.warn('[Product Package Complete] 客户端预览缺失,转服务器兜底处理', {
materialId: body.materialId,
count: missingClientPreviewCount,
})
}
const job = await createTaskDetailUploadJob({
materialId: body.materialId,
taskNo: body.taskNo,
dingtalkApprovalNo: body.dingtalkApprovalNo,
assets,
operatorUserId: (session.user as any).id,
options: {
allowSkuWithoutBarcodes: body.noBarcodes === true,
},
})
return NextResponse.json({
success: true,
data: {
jobId: job.jobId,
materialId: job.materialId,
status: job.status,
stage: job.stage,
progress: job.progress,
message: '文件已接收,正在后台处理',
},
}, { status: 202 })
} catch (error) {
console.error('[Product Package Complete] Error:', error)
if (error instanceof TaskDetailUploadQueueCapacityError) {
return NextResponse.json(
{ success: false, error: error.message, code: 'UPLOAD_QUEUE_FULL' },
{ status: error.statusCode, headers: { 'Retry-After': '60' } },
)
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : '服务器错误',
},
{ status: 500 },
)
}
}