diff --git a/app/api/canvas/3d/model-proxy/route.ts b/app/api/canvas/3d/model-proxy/route.ts new file mode 100644 index 000000000..2daf0d2da --- /dev/null +++ b/app/api/canvas/3d/model-proxy/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +function isAllowedModelHost(hostname: string) { + const normalized = hostname.toLowerCase(); + return ( + normalized === "tripo3d.com" || + normalized.endsWith(".tripo3d.com") + ); +} + +export async function GET(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const rawUrl = String(searchParams.get("url") || "").trim(); + if (!rawUrl) { + return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 }); + } + + let targetUrl: URL; + try { + targetUrl = new URL(rawUrl); + } catch { + return NextResponse.json({ success: false, error: "url 参数无效" }, { status: 400 }); + } + + if (targetUrl.protocol !== "https:") { + return NextResponse.json({ success: false, error: "仅支持 https 模型地址" }, { status: 400 }); + } + + if (!isAllowedModelHost(targetUrl.hostname)) { + return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); + } + + const forwardedHeaders = new Headers(); + const range = request.headers.get("range"); + if (range) { + forwardedHeaders.set("range", range); + } + + const upstream = await fetch(targetUrl.toString(), { + method: "GET", + headers: forwardedHeaders, + cache: "no-store", + }); + + if (!upstream.ok && upstream.status !== 206) { + const upstreamText = await upstream.text().catch(() => ""); + return NextResponse.json( + { + success: false, + error: upstreamText || `模型拉取失败(HTTP ${upstream.status})`, + }, + { status: upstream.status || 502 } + ); + } + + const responseHeaders = new Headers(); + const contentType = upstream.headers.get("content-type") || "application/octet-stream"; + responseHeaders.set("content-type", contentType); + + const contentLength = upstream.headers.get("content-length"); + if (contentLength) responseHeaders.set("content-length", contentLength); + + const contentRange = upstream.headers.get("content-range"); + if (contentRange) responseHeaders.set("content-range", contentRange); + + const acceptRanges = upstream.headers.get("accept-ranges"); + if (acceptRanges) responseHeaders.set("accept-ranges", acceptRanges); + + responseHeaders.set("cache-control", "private, no-store"); + + return new NextResponse(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "代理拉取 3D 模型失败", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/canvas/3d/tasks/[taskId]/route.ts b/app/api/canvas/3d/tasks/[taskId]/route.ts index 4b16f1d8d..6410d662c 100644 --- a/app/api/canvas/3d/tasks/[taskId]/route.ts +++ b/app/api/canvas/3d/tasks/[taskId]/route.ts @@ -8,10 +8,134 @@ import { } from "@/lib/tripo/client"; import { getTripoProviderConfig } from "@/lib/tripo/server"; import type { TripoTaskData } from "@/lib/tripo/types"; +import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; export const maxDuration = 30; +type TripoModelArchiveCacheItem = { + sourceUrl: string; + archivedUrl: string; +}; + +function shouldArchiveTripoModelUrl(modelUrl?: string | null) { + const raw = String(modelUrl || "").trim(); + if (!raw) return false; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + const hostname = parsed.hostname.toLowerCase(); + return ( + hostname === "tripo3d.com" || + hostname.endsWith(".tripo3d.com") + ); +} + +function resolveModelExtension(modelUrl: string) { + try { + const parsed = new URL(modelUrl); + const pathname = parsed.pathname.toLowerCase(); + const ext = pathname.split(".").pop() || ""; + const allowed = new Set(["glb", "gltf", "usdz", "fbx", "obj", "stl", "3mf"]); + if (allowed.has(ext)) return ext; + } catch { + // ignore + } + return "glb"; +} + +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; + modelUrl?: string | null; +}) { + const modelUrl = String(params.modelUrl || "").trim(); + if (!shouldArchiveTripoModelUrl(modelUrl)) { + return modelUrl || null; + } + + const cache = getTripoArchiveCache(); + const cached = cache.get(params.taskId); + if (cached && cached.sourceUrl === modelUrl) { + return cached.archivedUrl; + } + + const inflight = getTripoArchiveInflight(); + const existingTask = inflight.get(params.taskId); + if (existingTask) { + const result = await existingTask; + return result || modelUrl; + } + + const archiveTask = (async () => { + try { + const upstream = await fetch(modelUrl, { + 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 modelBuffer = await upstream.arrayBuffer(); + if (!modelBuffer || modelBuffer.byteLength <= 0) { + throw new Error("Tripo 模型文件为空"); + } + + const ext = resolveModelExtension(modelUrl); + const filename = `tripo-model-${params.taskId}.${ext}`; + const uploaded = await uploadFile(filename, modelBuffer); + cache.set(params.taskId, { + sourceUrl: modelUrl, + archivedUrl: uploaded.url, + }); + + console.log("[TripoModelArchive] success", { + taskId: params.taskId, + sourceHost: new URL(modelUrl).hostname, + bytes: modelBuffer.byteLength, + archivedUrl: uploaded.url, + }); + + return uploaded.url; + } catch (error) { + console.error("[TripoModelArchive] failed", { + taskId: params.taskId, + sourceUrl: modelUrl, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } finally { + inflight.delete(params.taskId); + } + })(); + + inflight.set(params.taskId, archiveTask); + const archivedUrl = await archiveTask; + return archivedUrl || modelUrl; +} + interface RouteParams { params: Promise<{ taskId: string }>; } @@ -39,6 +163,10 @@ export async function GET(_request: Request, { params }: RouteParams) { result.data?.output && typeof result.data.output === "object" ? result.data.output : {}; const modelUrl = pickTaskModelUrl(output as Record); const previewImageUrl = pickTaskPreviewImageUrl(output as Record); + const stableModelUrl = await archiveTripoModelIfNeeded({ + taskId: normalizedTaskId, + modelUrl, + }); return NextResponse.json({ success: true, @@ -48,7 +176,7 @@ export async function GET(_request: Request, { params }: RouteParams) { status: result.data.status, progress: result.data.progress ?? null, output, - modelUrl, + modelUrl: stableModelUrl, previewImageUrl, errorCode: result.data.error_code ?? null, errorMessage: result.data.error_msg ?? null, @@ -78,4 +206,3 @@ export async function GET(_request: Request, { params }: RouteParams) { ); } } - diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index ad0a876ca..da7126a01 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -223,6 +223,21 @@ export function Model3DViewer({ onLayersChange, onStatsChange, }: Model3DViewerProps) { + const resolvedModelUrl = useMemo(() => { + const normalized = String(modelUrl || "").trim(); + if (!normalized) return ""; + try { + const parsed = new URL(normalized); + const hostname = parsed.hostname.toLowerCase(); + if (hostname === "tripo3d.com" || hostname.endsWith(".tripo3d.com")) { + return `/api/canvas/3d/model-proxy?url=${encodeURIComponent(normalized)}`; + } + } catch { + // keep original url when parse fails (e.g. relative path) + } + return normalized; + }, [modelUrl]); + if (!modelUrl) { return ; } @@ -303,7 +318,7 @@ export function Model3DViewer({ onLayersChange={onLayersChange} onStatsChange={onStatsChange} settings={settings} - url={modelUrl} + url={resolvedModelUrl} />