fix(model3d): archive tripo models to default storage and stabilize preview
This commit is contained in:
parent
b5fdbd7c10
commit
31fba91269
95
app/api/canvas/3d/model-proxy/route.ts
Normal file
95
app/api/canvas/3d/model-proxy/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, TripoModelArchiveCacheItem>();
|
||||
}
|
||||
return globalState.__tripoModelArchiveCache as Map<string, TripoModelArchiveCacheItem>;
|
||||
}
|
||||
|
||||
function getTripoArchiveInflight() {
|
||||
const globalState = globalThis as any;
|
||||
if (!globalState.__tripoModelArchiveInflight) {
|
||||
globalState.__tripoModelArchiveInflight = new Map<string, Promise<string | null>>();
|
||||
}
|
||||
return globalState.__tripoModelArchiveInflight as Map<string, Promise<string | null>>;
|
||||
}
|
||||
|
||||
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<string, unknown>);
|
||||
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
|
||||
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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 <EmptyState previewImageUrl={previewImageUrl} />;
|
||||
}
|
||||
@ -303,7 +318,7 @@ export function Model3DViewer({
|
||||
onLayersChange={onLayersChange}
|
||||
onStatsChange={onStatsChange}
|
||||
settings={settings}
|
||||
url={modelUrl}
|
||||
url={resolvedModelUrl}
|
||||
/>
|
||||
<ContactShadows
|
||||
blur={2}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user