Your Name 432d315bc6
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: align canvas billing and 3d task routing
2026-04-27 16:56:40 +08:00

331 lines
9.7 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,
resolveTripoProviderConfigForTaskId,
} from "@/lib/tripo/server";
import type { TripoTaskData } from "@/lib/tripo/types";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 120;
type RemoteAssetArchiveCacheItem = {
sourceUrl: string;
archivedUrl: string;
};
function isLikelyArchivedStorageUrl(parsed: URL) {
const hostname = parsed.hostname.toLowerCase();
return hostname.includes("blob.vercel-storage.com");
}
function shouldArchiveRemoteAssetUrl(assetUrl?: string | null) {
const raw = String(assetUrl || "").trim();
if (!raw) return false;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
return !isLikelyArchivedStorageUrl(parsed);
}
function resolveExtensionFromContentType(contentType: string | null) {
const normalized = String(contentType || "").toLowerCase().split(";")[0]?.trim() || "";
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"model/gltf-binary": "glb",
"model/gltf+json": "gltf",
"model/stl": "stl",
"model/3mf": "3mf",
"model/vnd.usdz+zip": "usdz",
"application/zip": "zip",
"application/json": "json",
};
return map[normalized] || "";
}
function resolveRemoteAssetExtension(assetUrl: string, fallback: string, contentType?: string | null) {
const contentTypeExtension = resolveExtensionFromContentType(contentType || null);
if (contentTypeExtension) return contentTypeExtension;
try {
const parsed = new URL(assetUrl);
const pathname = parsed.pathname.toLowerCase();
const ext = pathname.split(".").pop() || "";
const allowed = new Set([
"glb",
"gltf",
"usdz",
"fbx",
"obj",
"stl",
"3mf",
"ply",
"spz",
"splat",
"ksplat",
"zip",
"jpg",
"jpeg",
"png",
"webp",
"gif",
]);
if (allowed.has(ext)) return ext;
} catch {
// ignore
}
return fallback;
}
function getTripoArchiveCache() {
const globalState = globalThis as any;
if (!globalState.__tripoModelArchiveCache) {
globalState.__tripoModelArchiveCache = new Map<string, RemoteAssetArchiveCacheItem>();
}
return globalState.__tripoModelArchiveCache as Map<string, RemoteAssetArchiveCacheItem>;
}
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;
cacheKey: string;
sourceUrl?: string | null;
filenamePrefix: string;
fallbackExtension: string;
}) {
const sourceUrl = String(params.sourceUrl || "").trim();
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) {
return sourceUrl || null;
}
const cache = getTripoArchiveCache();
const cached = cache.get(params.cacheKey);
if (cached && cached.sourceUrl === sourceUrl) {
return cached.archivedUrl;
}
const inflight = getTripoArchiveInflight();
const existingTask = inflight.get(params.cacheKey);
if (existingTask) {
const result = await existingTask;
return result || sourceUrl;
}
const archiveTask = (async () => {
try {
const upstream = await fetch(sourceUrl, {
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 contentType = upstream.headers.get("content-type");
if (/\btext\/html\b/i.test(String(contentType || ""))) {
throw new Error("Tripo 资源返回 HTML,已跳过归档以避免保存错误文件");
}
const assetBuffer = await upstream.arrayBuffer();
if (!assetBuffer || assetBuffer.byteLength <= 0) {
throw new Error("Tripo 资源文件为空");
}
const ext = resolveRemoteAssetExtension(
sourceUrl,
params.fallbackExtension,
contentType
);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: uploaded.url,
});
console.log("[TripoAssetArchive] success", {
taskId: params.taskId,
cacheKey: params.cacheKey,
sourceHost: new URL(sourceUrl).hostname,
bytes: assetBuffer.byteLength,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[TripoAssetArchive] failed", {
taskId: params.taskId,
cacheKey: params.cacheKey,
sourceUrl,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(params.cacheKey);
}
})();
inflight.set(params.cacheKey, archiveTask);
const archivedUrl = await archiveTask;
return archivedUrl || sourceUrl;
}
function replaceArchivedUrls(value: unknown, replacements: Map<string, string>): unknown {
if (typeof value === "string") {
return replacements.get(value) || value;
}
if (Array.isArray(value)) {
return value.map((item) => replaceArchivedUrls(item, replacements));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
replaceArchivedUrls(item, replacements),
])
);
}
return value;
}
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 { searchParams } = new URL(request.url);
const preferredProviderId = searchParams.get("providerId");
const config = preferredProviderId
? await getTripoProviderConfig(preferredProviderId)
: await resolveTripoProviderConfigForTaskId(normalizedTaskId);
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,
cacheKey: `tripo:${normalizedTaskId}:model`,
sourceUrl: modelUrl,
filenamePrefix: `tripo-model-${normalizedTaskId}`,
fallbackExtension: "glb",
});
const stablePreviewImageUrl = await archiveTripoModelIfNeeded({
taskId: normalizedTaskId,
cacheKey: `tripo:${normalizedTaskId}:preview`,
sourceUrl: previewImageUrl,
filenamePrefix: `tripo-preview-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const replacements = new Map<string, string>();
if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) {
replacements.set(modelUrl, stableModelUrl);
}
if (
previewImageUrl &&
stablePreviewImageUrl &&
previewImageUrl !== stablePreviewImageUrl
) {
replacements.set(previewImageUrl, stablePreviewImageUrl);
}
const archivedOutput =
replacements.size > 0
? replaceArchivedUrls(output, replacements)
: output;
const storageArchivedAt =
replacements.size > 0 ? new Date().toISOString() : null;
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: archivedOutput,
modelUrl: stableModelUrl,
previewImageUrl: stablePreviewImageUrl,
assetPayload: {
providerId: config.providerId || null,
providerName: config.providerName || null,
storageArchivedAt,
sourceModelUrl:
modelUrl && stableModelUrl && modelUrl !== stableModelUrl
? modelUrl
: null,
sourcePreviewImageUrl:
previewImageUrl &&
stablePreviewImageUrl &&
previewImageUrl !== stablePreviewImageUrl
? previewImageUrl
: null,
},
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 }
);
}
}