import { and, desc, eq, inArray } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { canvasProject, model3dAsset } from "@/lib/db/schema"; type Model3DTaskStatus = | "queued" | "running" | "success" | "failed" | "banned" | "expired" | "cancelled" | "unknown"; type Model3DTaskMode = | "text" | "image" | "multiview" | "batch" | "world-text" | "world-image" | "world-multi-image" | "world-video" | "convert" | "refine" | "texture" | "stylize" | "rigcheck" | "rig" | "retarget" | "segment" | "complete" | "lowpoly"; const FINAL_TASK_STATUS = new Set([ "queued", "running", "success", "failed", "banned", "expired", "cancelled", "unknown", ]); const TASK_MODE_SET = new Set([ "text", "image", "multiview", "batch", "world-text", "world-image", "world-multi-image", "world-video", "convert", "refine", "texture", "stylize", "rigcheck", "rig", "retarget", "segment", "complete", "lowpoly", ]); const TASK_LIST_LIMIT = 120; function normalizeTaskStatus(input: unknown): Model3DTaskStatus { const value = String(input || "").trim() as Model3DTaskStatus; return FINAL_TASK_STATUS.has(value) ? value : "unknown"; } function normalizeTaskMode(input: unknown): Model3DTaskMode { const value = String(input || "").trim() as Model3DTaskMode; return TASK_MODE_SET.has(value) ? value : "text"; } function readOptionalText(input: unknown): string | null { const normalized = String(input || "").trim(); return normalized ? normalized : null; } function readProvider(input: unknown): "tripo-3d" | "worldlabs-3d" { return String(input || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d"; } function readJsonObject(input: unknown): Record | null { return input && typeof input === "object" && !Array.isArray(input) ? (input as Record) : null; } function normalizeAssetPayloadModelAlias( payload: unknown, entries: Awaited< ReturnType >["worldModels"], ) { const objectValue = readJsonObject(payload); if (!objectValue) return objectValue; const nextModel = resolveThreeDModelAliasId(objectValue.model, entries); return { ...objectValue, ...(nextModel ? { model: nextModel } : {}), }; } function readProgress(input: unknown): number | null { if (input === undefined || input === null || input === "") return null; const value = Number(input); if (!Number.isFinite(value)) return null; return Math.max(0, Math.min(100, Math.round(value))); } function createShareToken() { return randomUUID().replace(/-/g, ""); } function readDate(input: unknown): Date | null { if (!input) return null; if (input instanceof Date && !Number.isNaN(input.getTime())) return input; if (typeof input === "number" && Number.isFinite(input)) { const fromNumber = new Date(input); if (!Number.isNaN(fromNumber.getTime())) return fromNumber; } if (typeof input === "string" && input.trim()) { const fromText = new Date(input); if (!Number.isNaN(fromText.getTime())) return fromText; } return null; } async function ensureProjectOwnership(projectId: string, userId: string) { const [project] = await db .select({ id: canvasProject.id }) .from(canvasProject) .where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId))) .limit(1); return Boolean(project); } export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const { id: projectId } = await params; const ownsProject = await ensureProjectOwnership(projectId, session.user.id); if (!ownsProject) { return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 }); } const assets = await db .select() .from(model3dAsset) .where( and( eq(model3dAsset.projectId, projectId), eq(model3dAsset.userId, session.user.id) ) ) .orderBy(desc(model3dAsset.updatedAt)) .limit(TASK_LIST_LIMIT); const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const normalizedAssets = assets.map((asset) => { const entries = asset.provider === "worldlabs-3d" ? modelAliasConfig.worldModels : modelAliasConfig.tripoModels; return { ...asset, modelVersion: resolveThreeDModelAliasId(asset.modelVersion, entries), assetPayload: asset.provider === "worldlabs-3d" ? normalizeAssetPayloadModelAlias( asset.assetPayload, modelAliasConfig.worldModels, ) : asset.assetPayload, }; }); return NextResponse.json({ success: true, data: normalizedAssets }); } catch (error) { console.error("[Model3DAssets] GET failed:", error); return NextResponse.json({ success: false, error: "获取 3D 资产失败" }, { status: 500 }); } } export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const { id: projectId } = await params; const ownsProject = await ensureProjectOwnership(projectId, session.user.id); if (!ownsProject) { return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 }); } const body = await request.json().catch(() => ({})); const rawAsset = ((body as any)?.asset || body || {}) as Record; const taskId = String(rawAsset.taskId || "").trim(); if (!taskId) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const status = normalizeTaskStatus(rawAsset.status); const mode = normalizeTaskMode(rawAsset.mode); const provider = readProvider(rawAsset.provider); const prompt = String(rawAsset.prompt || "").trim(); const modelVersion = resolveThreeDModelAliasId( rawAsset.modelVersion, provider === "worldlabs-3d" ? modelAliasConfig.worldModels : modelAliasConfig.tripoModels, ); const progress = readProgress(rawAsset.progress); const modelUrl = readOptionalText(rawAsset.modelUrl); const previewImageUrl = readOptionalText(rawAsset.previewImageUrl); const error = readOptionalText(rawAsset.error); const sourceTaskId = readOptionalText(rawAsset.sourceTaskId); const exportFormat = readOptionalText(rawAsset.exportFormat); const assetPayload = provider === "worldlabs-3d" ? normalizeAssetPayloadModelAlias( rawAsset.assetPayload, modelAliasConfig.worldModels, ) : readJsonObject(rawAsset.assetPayload); const createdAt = readDate(rawAsset.createdAt); const now = new Date(); const [saved] = await db .insert(model3dAsset) .values({ userId: session.user.id, projectId, taskId, provider, status, mode, prompt, modelVersion, progress, modelUrl, previewImageUrl, error, sourceTaskId, exportFormat, assetPayload, createdAt: createdAt || now, }) .onConflictDoUpdate({ target: [model3dAsset.projectId, model3dAsset.taskId], set: { userId: session.user.id, provider, status, mode, prompt, modelVersion, progress, modelUrl, previewImageUrl, error, sourceTaskId, exportFormat, assetPayload, updatedAt: now, }, }) .returning(); return NextResponse.json({ success: true, data: saved }); } catch (error) { console.error("[Model3DAssets] POST failed:", error); return NextResponse.json({ success: false, error: "保存 3D 资产失败" }, { status: 500 }); } } export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const { id: projectId } = await params; const ownsProject = await ensureProjectOwnership(projectId, session.user.id); if (!ownsProject) { return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 }); } const searchTaskId = request.nextUrl.searchParams.get("taskId"); let bodyTaskId = ""; if (!searchTaskId) { const body = await request.json().catch(() => ({})); bodyTaskId = String((body as any)?.taskId || "").trim(); } const taskId = String(searchTaskId || bodyTaskId || "").trim(); const conditions = [ eq(model3dAsset.projectId, projectId), eq(model3dAsset.userId, session.user.id), ]; if (taskId) { conditions.push(eq(model3dAsset.taskId, taskId)); } const deleted = await db .delete(model3dAsset) .where(and(...conditions)) .returning({ id: model3dAsset.id }); return NextResponse.json({ success: true, data: { deletedCount: deleted.length }, }); } catch (error) { console.error("[Model3DAssets] DELETE failed:", error); return NextResponse.json({ success: false, error: "清理 3D 资产失败" }, { status: 500 }); } } export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const { id: projectId } = await params; const ownsProject = await ensureProjectOwnership(projectId, session.user.id); if (!ownsProject) { return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 }); } const body = await request.json().catch(() => ({})); const rawTaskIds = Array.isArray((body as any)?.taskIds) ? (body as any).taskIds : [(body as any)?.taskId]; const taskIds = Array.from( new Set( rawTaskIds .map((value: unknown) => String(value || "").trim()) .filter(Boolean) ) ); if (taskIds.length === 0) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } if (taskIds.length > 30) { return NextResponse.json( { success: false, error: "一次最多分享 30 个 3D 世界" }, { status: 400 } ); } const existingAssets = await db .select({ id: model3dAsset.id, taskId: model3dAsset.taskId, provider: model3dAsset.provider, status: model3dAsset.status, shareEnabled: model3dAsset.shareEnabled, shareToken: model3dAsset.shareToken, }) .from(model3dAsset) .where( and( eq(model3dAsset.projectId, projectId), eq(model3dAsset.userId, session.user.id), inArray(model3dAsset.taskId, taskIds) ) ); if (existingAssets.length !== taskIds.length) { return NextResponse.json({ success: false, error: "资产不存在" }, { status: 404 }); } if (existingAssets.some((asset) => asset.provider !== "worldlabs-3d")) { return NextResponse.json( { success: false, error: "当前仅支持分享 3D 世界资产" }, { status: 400 } ); } if (existingAssets.some((asset) => asset.status !== "success")) { return NextResponse.json( { success: false, error: "仅成功生成的 3D 世界支持分享" }, { status: 400 } ); } const shouldEnable = (body as any)?.shareEnabled !== false; const existingShareToken = existingAssets.find( (asset) => asset.shareEnabled && asset.shareToken )?.shareToken; const shareToken = shouldEnable ? taskIds.length > 1 ? existingShareToken || createShareToken() : existingAssets[0]?.shareToken || createShareToken() : null; const nextValues = shouldEnable ? { shareEnabled: true, shareToken, sharedAt: new Date(), updatedAt: new Date(), } : { shareEnabled: false, shareToken: null, sharedAt: null, updatedAt: new Date(), }; const updatedAssets = await db .update(model3dAsset) .set(nextValues) .where(inArray(model3dAsset.id, existingAssets.map((asset) => asset.id))) .returning(); const updatedAssetByTaskId = new Map( updatedAssets.map((asset) => [asset.taskId, asset]) ); const orderedUpdatedAssets = taskIds .map((id) => updatedAssetByTaskId.get(id)) .filter((asset): asset is (typeof updatedAssets)[number] => Boolean(asset)); return NextResponse.json({ success: true, data: orderedUpdatedAssets[0] || updatedAssets[0] || null, items: orderedUpdatedAssets, shareToken, }); } catch (error) { console.error("[Model3DAssets] PATCH failed:", error); return NextResponse.json({ success: false, error: "更新 3D 世界分享状态失败" }, { status: 500 }); } }