import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { consumeUserPoints, getPointsSystemConfig, getUserById, } from "@/lib/db/queries"; import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { getTripoProviderConfig } from "@/lib/tripo/server"; import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; export const runtime = "nodejs"; export const maxDuration = 60; type CreateTaskRequest = { taskType: | "text_to_model" | "image_to_model" | "multiview_to_model" | "convert_model" | "refine_model" | "stylize_model" | "texture_model" | "animate_prerigcheck" | "animate_rig" | "animate_retarget" | "mesh_segmentation" | "mesh_completion" | "highpoly_to_lowpoly"; prompt?: string; imageToken?: string; imageUrl?: string; imageType?: string; file?: Record; files?: Array>; imageTokens?: string[]; imageUrls?: string[]; originalModelTaskId?: string; draftModelTaskId?: string; format?: "GLTF" | "USDZ" | "FBX" | "OBJ" | "STL" | "3MF"; style?: "lego" | "voxel" | "voronoi" | "minecraft"; blockSize?: number; outFormat?: "glb" | "fbx"; rigType?: "biped" | "quadruped" | "hexapod" | "octopod" | "avian" | "serpentine" | "aquatic" | "others"; spec?: "mixamo" | "tripo"; animation?: string; animations?: string[]; texturePromptText?: string; texturePromptImageToken?: string; texturePromptStyleImageToken?: string; partNames?: string[]; bakeAnimation?: boolean; exportWithGeometry?: boolean; modelVersion?: string; negativePrompt?: string; faceLimit?: number; texture?: boolean; pbr?: boolean; imageSeed?: number; modelSeed?: number; textureSeed?: number; textureQuality?: "standard" | "detailed"; geometryQuality?: "standard" | "detailed"; textureAlignment?: "original_image" | "geometry"; autoSize?: boolean; orientation?: "default" | "align_image"; quad?: boolean; compress?: boolean; generateParts?: boolean; smartLowPoly?: boolean; forceSymmetry?: boolean; flattenBottom?: boolean; flattenBottomThreshold?: number; textureSize?: number; textureFormat?: string; scaleFactor?: number; pivotToCenterBottom?: boolean; withAnimation?: boolean; packUv?: boolean; bake?: boolean; exportVertexColors?: boolean; fbxPreset?: "blender" | "mixamo" | "3dsmax"; exportOrientation?: "+x" | "+y" | "-x" | "-y"; animateInPlace?: boolean; }; function hasOwn(data: Record, key: string) { return Object.prototype.hasOwnProperty.call(data, key); } function readOptionalTrimmedText(input: unknown) { const value = String(input || "").trim(); return value || null; } function assignMapped( payload: Record, source: Record, map: Record ) { for (const [sourceKey, targetKey] of Object.entries(map)) { if (!hasOwn(source, sourceKey)) continue; const value = source[sourceKey]; if (value === undefined || value === null) continue; if (typeof value === "string" && !value.trim()) continue; payload[targetKey] = value; } } function shouldCharge3DPoints(taskType: CreateTaskRequest["taskType"]) { return ( taskType === "text_to_model" || taskType === "image_to_model" || taskType === "multiview_to_model" ); } export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const body = (await request.json()) as CreateTaskRequest; const taskType = body.taskType; if (!taskType) { return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 }); } const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const payload: Record = { type: taskType, }; if (taskType === "text_to_model") { const prompt = String(body.prompt || "").trim(); if (!prompt) { return NextResponse.json({ success: false, error: "请输入提示词" }, { status: 400 }); } payload.prompt = prompt; } if (taskType === "image_to_model") { const imageToken = String(body.imageToken || "").trim(); const imageUrl = String(body.imageUrl || "").trim(); const inputFile = imageToken ? { type: String(body.imageType || "jpg"), file_token: imageToken, } : imageUrl ? { type: String(body.imageType || "jpg"), url: imageUrl, } : body.file; if (!inputFile || typeof inputFile !== "object") { return NextResponse.json( { success: false, error: "图生3D缺少图片输入(imageToken 或 imageUrl)" }, { status: 400 } ); } payload.file = inputFile; } if (taskType === "multiview_to_model") { const inputFiles: Array> = []; if (Array.isArray(body.files)) { for (const item of body.files) { if (item && typeof item === "object") { inputFiles.push(item); } } } if (inputFiles.length === 0 && Array.isArray(body.imageTokens)) { for (const token of body.imageTokens) { const normalized = String(token || "").trim(); if (!normalized) continue; inputFiles.push({ type: String(body.imageType || "jpg"), file_token: normalized, }); } } if (inputFiles.length === 0 && Array.isArray(body.imageUrls)) { for (const imageUrl of body.imageUrls) { const normalized = String(imageUrl || "").trim(); if (!normalized) continue; inputFiles.push({ type: String(body.imageType || "jpg"), url: normalized, }); } } if (inputFiles.length < 2) { return NextResponse.json( { success: false, error: "多视图生成至少需要 2 张参考图" }, { status: 400 } ); } payload.files = inputFiles; } if (taskType === "convert_model") { const originalModelTaskId = String(body.originalModelTaskId || "").trim(); if (!originalModelTaskId) { return NextResponse.json( { success: false, error: "缺少 originalModelTaskId" }, { status: 400 } ); } if (!body.format) { return NextResponse.json({ success: false, error: "缺少 format" }, { status: 400 }); } payload.original_model_task_id = originalModelTaskId; payload.format = body.format; } if ( taskType === "stylize_model" || taskType === "texture_model" || taskType === "animate_prerigcheck" || taskType === "animate_rig" || taskType === "animate_retarget" || taskType === "mesh_segmentation" || taskType === "mesh_completion" || taskType === "highpoly_to_lowpoly" ) { const originalModelTaskId = String(body.originalModelTaskId || "").trim(); if (!originalModelTaskId) { return NextResponse.json( { success: false, error: "缺少 originalModelTaskId" }, { status: 400 } ); } payload.original_model_task_id = originalModelTaskId; } if (taskType === "stylize_model") { if (!body.style) { return NextResponse.json({ success: false, error: "缺少 style" }, { status: 400 }); } payload.style = body.style; } if (taskType === "animate_retarget") { const singleAnimation = String(body.animation || "").trim(); const animationList = Array.isArray(body.animations) ? body.animations.map((item) => String(item || "").trim()).filter(Boolean) : []; if (!singleAnimation && animationList.length === 0) { return NextResponse.json( { success: false, error: "缺少 animation / animations" }, { status: 400 } ); } if (singleAnimation) { payload.animation = singleAnimation; } else { payload.animations = animationList; } } if (taskType === "refine_model") { const draftModelTaskId = String(body.draftModelTaskId || "").trim(); if (!draftModelTaskId) { return NextResponse.json( { success: false, error: "缺少 draftModelTaskId" }, { status: 400 } ); } payload.draft_model_task_id = draftModelTaskId; } if (taskType === "texture_model") { const texturePromptText = String(body.texturePromptText || "").trim(); const texturePromptImageToken = String(body.texturePromptImageToken || "").trim(); const texturePromptStyleImageToken = String(body.texturePromptStyleImageToken || "").trim(); if (texturePromptText || texturePromptImageToken || texturePromptStyleImageToken) { const texturePrompt: Record = {}; if (texturePromptText) texturePrompt.text = texturePromptText; if (texturePromptImageToken) { texturePrompt.image = { type: String(body.imageType || "jpg"), file_token: texturePromptImageToken, }; } if (texturePromptStyleImageToken) { texturePrompt.style_image = { type: String(body.imageType || "jpg"), file_token: texturePromptStyleImageToken, }; } payload.texture_prompt = texturePrompt; } } assignMapped(payload, body as Record, { negativePrompt: "negative_prompt", faceLimit: "face_limit", texture: "texture", pbr: "pbr", imageSeed: "image_seed", modelSeed: "model_seed", textureSeed: "texture_seed", textureQuality: "texture_quality", geometryQuality: "geometry_quality", textureAlignment: "texture_alignment", autoSize: "auto_size", orientation: "orientation", quad: "quad", generateParts: "generate_parts", smartLowPoly: "smart_low_poly", forceSymmetry: "force_symmetry", flattenBottom: "flatten_bottom", flattenBottomThreshold: "flatten_bottom_threshold", textureSize: "texture_size", textureFormat: "texture_format", scaleFactor: "scale_factor", blockSize: "block_size", outFormat: "out_format", rigType: "rig_type", spec: "spec", partNames: "part_names", bakeAnimation: "bake_animation", exportWithGeometry: "export_with_geometry", pivotToCenterBottom: "pivot_to_center_bottom", withAnimation: "with_animation", packUv: "pack_uv", bake: "bake", exportVertexColors: "export_vertex_colors", fbxPreset: "fbx_preset", exportOrientation: "export_orientation", animateInPlace: "animate_in_place", }); if ( taskType === "text_to_model" || taskType === "image_to_model" || taskType === "multiview_to_model" ) { payload.model_version = resolveThreeDModelRealId( readOptionalTrimmedText(body.modelVersion), modelAliasConfig.tripoModels, ); } if (body.compress === true) { payload.compress = "geometry"; } const pointsConfig = await getPointsSystemConfig(); const shouldChargePoints = shouldCharge3DPoints(taskType); const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel)) ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel))) : 0; if ( shouldChargePoints && pointsConfig.enabled && unitCost > 0 && pointsConfig.blockOnInsufficient !== false ) { const userData = await getUserById({ id: session.user.id }); if (!userData?.isHighVip) { const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0)); if (currentBalance < unitCost) { return NextResponse.json( { success: false, error: "积分不足", code: "insufficient_points", message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`, balance: currentBalance, expectedCost: unitCost, unitCost, }, { status: 402 } ); } } } const config = await getTripoProviderConfig(); const result = await tripoRequest>(config, { method: "POST", path: "/task", json: payload, }); const taskId = String(result.data?.task_id || result.data?.id || "").trim(); if (!taskId) { return NextResponse.json( { success: false, error: "Tripo 返回缺少 task_id", traceId: result.traceId, raw: result.data, }, { status: 502 } ); } if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) { try { const consumeResult = await consumeUserPoints({ userId: session.user.id, amount: unitCost, reason: "model3d_generation", modelId: "tripo-3d", note: "3D模型生成消耗积分", }); if (!consumeResult.ok) { console.warn( `[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}` ); } else if (consumeResult.skipped) { console.log( `[Points] Skipped ${unitCost} 3D points for VIP user=${session.user.id} task=${taskId}` ); } else { console.log( `[Points] Consumed ${unitCost} points for 3D model generation. user=${session.user.id} task=${taskId} balance=${consumeResult.balance}` ); } } catch (error) { console.error("[Points] Failed to consume 3D points:", error); } } return NextResponse.json({ success: true, data: { taskId, status: String(result.data?.status || "queued"), raw: result.data, }, traceId: result.traceId, }); } catch (error) { if (error instanceof TripoApiError) { return NextResponse.json( { success: false, error: error.message, code: error.code, suggestion: error.suggestion, traceId: error.traceId, }, { status: error.httpStatus || 502 } ); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "创建 3D 任务失败", }, { status: 500 } ); } }