209 lines
5.9 KiB
TypeScript
209 lines
5.9 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|