import { NextResponse } from "next/server"; import { and, eq, sql } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { refundUserPoints } from "@/lib/db/queries"; import { pointsLedger } from "@/lib/db/schema"; import { createLogger } from "@/lib/observability/logger"; import { uploadFile } from "@/lib/storage/service"; import { TripoApiError, pickTaskModelUrl, pickTaskPreviewImageUrl, tripoRequest, } from "@/lib/tripo/client"; import { getTripoProviderConfig, resolveTripoProviderConfigForTaskId, } from "@/lib/tripo/server"; import type { TripoTaskData } from "@/lib/tripo/types"; export const runtime = "nodejs"; export const maxDuration = 120; const logger = createLogger("canvas-3d-task-status"); type RemoteAssetArchiveCacheItem = { sourceUrl: string; archivedUrl: string; }; function isLikelyArchivedStorageUrl(parsed: URL) { const hostname = parsed.hostname.toLowerCase(); return hostname.includes("blob.vercel-storage.com"); } function shouldArchiveRemoteAssetUrl(assetUrl?: string | null) { const raw = String(assetUrl || "").trim(); if (!raw) return false; let parsed: URL; try { parsed = new URL(raw); } catch { return false; } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; return !isLikelyArchivedStorageUrl(parsed); } function resolveExtensionFromContentType(contentType: string | null) { const normalized = String(contentType || "").toLowerCase().split(";")[0]?.trim() || ""; const map: Record = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif", "model/gltf-binary": "glb", "model/gltf+json": "gltf", "model/stl": "stl", "model/3mf": "3mf", "model/vnd.usdz+zip": "usdz", "application/zip": "zip", "application/json": "json", }; return map[normalized] || ""; } function resolveRemoteAssetExtension(assetUrl: string, fallback: string, contentType?: string | null) { const contentTypeExtension = resolveExtensionFromContentType(contentType || null); if (contentTypeExtension) return contentTypeExtension; try { const parsed = new URL(assetUrl); const pathname = parsed.pathname.toLowerCase(); const ext = pathname.split(".").pop() || ""; const allowed = new Set([ "glb", "gltf", "usdz", "fbx", "obj", "stl", "3mf", "ply", "spz", "splat", "ksplat", "zip", "jpg", "jpeg", "png", "webp", "gif", ]); if (allowed.has(ext)) return ext; } catch { // ignore } return fallback; } function getTripoArchiveCache() { const globalState = globalThis as any; if (!globalState.__tripoModelArchiveCache) { globalState.__tripoModelArchiveCache = new Map(); } return globalState.__tripoModelArchiveCache as Map; } function getTripoArchiveInflight() { const globalState = globalThis as any; if (!globalState.__tripoModelArchiveInflight) { globalState.__tripoModelArchiveInflight = new Map>(); } return globalState.__tripoModelArchiveInflight as Map>; } async function archiveTripoModelIfNeeded(params: { taskId: string; cacheKey: string; sourceUrl?: string | null; filenamePrefix: string; fallbackExtension: string; }) { const sourceUrl = String(params.sourceUrl || "").trim(); if (!shouldArchiveRemoteAssetUrl(sourceUrl)) { return sourceUrl || null; } const cache = getTripoArchiveCache(); const cached = cache.get(params.cacheKey); if (cached && cached.sourceUrl === sourceUrl) { return cached.archivedUrl; } const inflight = getTripoArchiveInflight(); const existingTask = inflight.get(params.cacheKey); if (existingTask) { const result = await existingTask; return result || sourceUrl; } const archiveTask = (async () => { try { const upstream = await fetch(sourceUrl, { method: "GET", headers: { "User-Agent": "AI-Chatbot-3D-Archiver/1.0", }, cache: "no-store", }); if (!upstream.ok) { throw new Error(`下载 Tripo 资源失败(HTTP ${upstream.status})`); } const contentType = upstream.headers.get("content-type"); if (/\btext\/html\b/i.test(String(contentType || ""))) { throw new Error("Tripo 资源返回 HTML,已跳过归档以避免保存错误文件"); } const assetBuffer = await upstream.arrayBuffer(); if (!assetBuffer || assetBuffer.byteLength <= 0) { throw new Error("Tripo 资源文件为空"); } const ext = resolveRemoteAssetExtension( sourceUrl, params.fallbackExtension, contentType ); const filename = `${params.filenamePrefix}.${ext}`; const uploaded = await uploadFile(filename, assetBuffer); cache.set(params.cacheKey, { sourceUrl, archivedUrl: uploaded.url, }); logger.info("asset archive completed", { taskId: params.taskId, cacheKey: params.cacheKey, sourceHost: new URL(sourceUrl).hostname, bytes: assetBuffer.byteLength, archivedUrl: uploaded.url, }); return uploaded.url; } catch (error) { logger.warn("asset archive failed", { taskId: params.taskId, cacheKey: params.cacheKey, sourceUrl, error, }); return null; } finally { inflight.delete(params.cacheKey); } })(); inflight.set(params.cacheKey, archiveTask); const archivedUrl = await archiveTask; return archivedUrl || sourceUrl; } function replaceArchivedUrls(value: unknown, replacements: Map): unknown { if (typeof value === "string") { return replacements.get(value) || value; } if (Array.isArray(value)) { return value.map((item) => replaceArchivedUrls(item, replacements)); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record).map(([key, item]) => [ key, replaceArchivedUrls(item, replacements), ]) ); } return value; } interface RouteParams { params: Promise<{ taskId: string }>; } function isFailedTripoStatus(status?: string | null) { const normalized = String(status || "").trim().toLowerCase(); return ( normalized === "failed" || normalized === "error" || normalized === "cancelled" || normalized === "canceled" ); } function getModel3DTaskRefundNote(taskId: string) { return `3D模型任务失败退回积分(${taskId})`; } async function refundModel3DPointsForFailedTask(params: { userId: string; taskId: string; errorMessage?: string | null; }) { const chargeLedgers = await db .select({ id: pointsLedger.id, amount: pointsLedger.amount, modelId: pointsLedger.modelId, }) .from(pointsLedger) .where( and( eq(pointsLedger.userId, params.userId), eq(pointsLedger.reason, "model3d_task"), sql`${pointsLedger.note} LIKE ${`3D模型任务消耗积分(% / ${params.taskId})`}`, ), ) .limit(1); const chargeLedger = chargeLedgers[0]; const refundAmount = Math.abs(Number(chargeLedger?.amount || 0)); if (!chargeLedger || refundAmount <= 0) { return 0; } const refundNote = getModel3DTaskRefundNote(params.taskId); const existingRefund = await db .select({ id: pointsLedger.id }) .from(pointsLedger) .where( and( eq(pointsLedger.userId, params.userId), eq(pointsLedger.reason, "model3d_task_refund"), eq(pointsLedger.note, refundNote), ), ) .limit(1); if (existingRefund[0]) { return refundAmount; } const refundResult = await refundUserPoints({ userId: params.userId, amount: refundAmount, reason: "model3d_task_refund", modelId: chargeLedger.modelId || "tripo-3d", note: refundNote, }); if (!refundResult.ok) { logger.error("points refund failed", { taskId: params.taskId, userId: params.userId, errorMessage: params.errorMessage, }); return 0; } return refundAmount; } export async function GET(request: Request, { params }: RouteParams) { let refundContext: { userId: string; taskId: string } | null = null; try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const { taskId } = await params; const normalizedTaskId = String(taskId || "").trim(); if (!normalizedTaskId) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } refundContext = { userId: session.user.id, taskId: normalizedTaskId }; const { searchParams } = new URL(request.url); const preferredProviderId = searchParams.get("providerId"); const config = preferredProviderId ? await getTripoProviderConfig(preferredProviderId) : await resolveTripoProviderConfigForTaskId(normalizedTaskId); const result = await tripoRequest(config, { method: "GET", path: `/task/${encodeURIComponent(normalizedTaskId)}`, }); const output = result.data?.output && typeof result.data.output === "object" ? result.data.output : {}; const taskStatus = String(result.data.status || ""); const errorMessage = String(result.data.error_msg || "").trim(); const refundedPoints = isFailedTripoStatus(taskStatus) ? await refundModel3DPointsForFailedTask({ userId: session.user.id, taskId: normalizedTaskId, errorMessage, }).catch((refundError) => { logger.error("points refund handler failed", refundError, { taskId: normalizedTaskId, userId: session.user.id, }); return 0; }) : 0; const modelUrl = pickTaskModelUrl(output as Record); const previewImageUrl = pickTaskPreviewImageUrl(output as Record); const stableModelUrl = await archiveTripoModelIfNeeded({ taskId: normalizedTaskId, cacheKey: `tripo:${normalizedTaskId}:model`, sourceUrl: modelUrl, filenamePrefix: `tripo-model-${normalizedTaskId}`, fallbackExtension: "glb", }); const stablePreviewImageUrl = await archiveTripoModelIfNeeded({ taskId: normalizedTaskId, cacheKey: `tripo:${normalizedTaskId}:preview`, sourceUrl: previewImageUrl, filenamePrefix: `tripo-preview-${normalizedTaskId}`, fallbackExtension: "jpg", }); const replacements = new Map(); if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) { replacements.set(modelUrl, stableModelUrl); } if ( previewImageUrl && stablePreviewImageUrl && previewImageUrl !== stablePreviewImageUrl ) { replacements.set(previewImageUrl, stablePreviewImageUrl); } const archivedOutput = replacements.size > 0 ? replaceArchivedUrls(output, replacements) : output; const storageArchivedAt = replacements.size > 0 ? new Date().toISOString() : null; return NextResponse.json({ success: true, data: { taskId: result.data.task_id, type: result.data.type, status: result.data.status, progress: result.data.progress ?? null, output: archivedOutput, modelUrl: stableModelUrl, previewImageUrl: stablePreviewImageUrl, assetPayload: { providerId: config.providerId || null, providerName: config.providerName || null, storageArchivedAt, sourceModelUrl: modelUrl && stableModelUrl && modelUrl !== stableModelUrl ? modelUrl : null, sourcePreviewImageUrl: previewImageUrl && stablePreviewImageUrl && previewImageUrl !== stablePreviewImageUrl ? previewImageUrl : null, }, errorCode: result.data.error_code ?? null, errorMessage: result.data.error_msg ?? null, refundedPoints, }, traceId: result.traceId, }); } catch (error) { if (error instanceof TripoApiError) { const shouldRefundMissingTask = error.code === 2001 || /task\s+is\s+not\s+found/i.test(error.message); const missingTaskRefundContext = refundContext; const refundedPoints = shouldRefundMissingTask && missingTaskRefundContext ? await refundModel3DPointsForFailedTask({ ...missingTaskRefundContext, errorMessage: error.message, }).catch((refundError) => { logger.error("missing task refund failed", refundError, { taskId: missingTaskRefundContext.taskId, userId: missingTaskRefundContext.userId, }); return 0; }) : 0; return NextResponse.json( { success: false, error: error.message, code: error.code, suggestion: error.suggestion, traceId: error.traceId, refundedPoints, }, { status: error.httpStatus || 502 } ); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "查询任务失败", }, { status: 500 } ); } }