196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
/**
|
||
* 任务-详情上传完成接口(改造版)
|
||
* 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,
|
||
} 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 },
|
||
)
|
||
}
|
||
|
||
// 初始化素材的 zip 路径必须为空。已有路径说明上一次后台处理已经写入了
|
||
// 部分持久化结果,禁止再次入队覆盖,避免重复资产和悬空文件。
|
||
if (material.zipFilePath || material._count?.assets > 0) {
|
||
return NextResponse.json(
|
||
{ success: false, error: '该素材已有后台处理结果,请刷新素材状态后再操作' },
|
||
{ status: 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 })
|
||
}
|
||
|
||
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)
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '服务器错误',
|
||
},
|
||
{ status: 500 },
|
||
)
|
||
}
|
||
}
|