fix(model3d): archive tripo models to default storage and stabilize preview
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 10:48:34 +08:00
parent b5fdbd7c10
commit 31fba91269
3 changed files with 240 additions and 3 deletions

View 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 }
);
}
}

View File

@ -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) {
);
}
}

View File

@ -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}