Your Name 31fba91269
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(model3d): archive tripo models to default storage and stabilize preview
2026-03-06 10:48:34 +08:00

209 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
TripoApiError,
pickTaskModelUrl,
pickTaskPreviewImageUrl,
tripoRequest,
} 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 }>;
}
export async function GET(_request: Request, { params }: RouteParams) {
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 });
}
const config = await getTripoProviderConfig();
const result = await tripoRequest<TripoTaskData>(config, {
method: "GET",
path: `/task/${encodeURIComponent(normalizedTaskId)}`,
});
const output =
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,
data: {
taskId: result.data.task_id,
type: result.data.type,
status: result.data.status,
progress: result.data.progress ?? null,
output,
modelUrl: stableModelUrl,
previewImageUrl,
errorCode: result.data.error_code ?? null,
errorMessage: result.data.error_msg ?? null,
},
traceId: result.traceId,
});
} catch (error) {
if (error instanceof TripoApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
code: error.code,
suggestion: error.suggestion,
traceId: error.traceId,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询任务失败",
},
{ status: 500 }
);
}
}