559 lines
17 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 { and, eq, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { refundUserPoints } from "@/lib/db/queries";
import { pointsLedger } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
import {
TripoApiError,
pickTaskModelUrl,
pickTaskPreviewImageUrl,
tripoRequest,
} from "@/lib/tripo/client";
import {
getTripoProviderConfig,
resolveTripoProviderConfigForTaskId,
} from "@/lib/tripo/server";
import type { TripoTaskData } from "@/lib/tripo/types";
import { getConfiguredVip98kTaskStatus } from "@/lib/ai/vip98k-runtime";
export const runtime = "nodejs";
export const maxDuration = 120;
const logger = createLogger("canvas-3d-task-status");
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,
});
logger.info("asset archive completed", {
taskId: params.taskId,
cacheKey: params.cacheKey,
sourceHost: new URL(sourceUrl).hostname,
bytes: assetBuffer.byteLength,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
logger.warn("asset archive failed", {
taskId: params.taskId,
cacheKey: params.cacheKey,
sourceUrl,
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 }>;
}
function isFailedTripoStatus(status?: string | null) {
const normalized = String(status || "").trim().toLowerCase();
return (
normalized === "failed" ||
normalized === "error" ||
normalized === "cancelled" ||
normalized === "canceled"
);
}
function getModel3DTaskRefundNote(taskId: string) {
return `3D模型任务失败退回积分(${taskId})`;
}
async function refundModel3DPointsForFailedTask(params: {
userId: string;
taskId: string;
errorMessage?: string | null;
}) {
const chargeLedgers = await db
.select({
id: pointsLedger.id,
amount: pointsLedger.amount,
modelId: pointsLedger.modelId,
})
.from(pointsLedger)
.where(
and(
eq(pointsLedger.userId, params.userId),
eq(pointsLedger.reason, "model3d_task"),
sql`${pointsLedger.note} LIKE ${`3D模型任务消耗积分(% / ${params.taskId})`}`,
),
)
.limit(1);
const chargeLedger = chargeLedgers[0];
const refundAmount = Math.abs(Number(chargeLedger?.amount || 0));
if (!chargeLedger || refundAmount <= 0) {
return 0;
}
const refundNote = getModel3DTaskRefundNote(params.taskId);
const existingRefund = await db
.select({ id: pointsLedger.id })
.from(pointsLedger)
.where(
and(
eq(pointsLedger.userId, params.userId),
eq(pointsLedger.reason, "model3d_task_refund"),
eq(pointsLedger.note, refundNote),
),
)
.limit(1);
if (existingRefund[0]) {
return refundAmount;
}
const refundResult = await refundUserPoints({
userId: params.userId,
amount: refundAmount,
reason: "model3d_task_refund",
modelId: chargeLedger.modelId || "tripo-3d",
note: refundNote,
});
if (!refundResult.ok) {
logger.error("points refund failed", {
taskId: params.taskId,
userId: params.userId,
errorMessage: params.errorMessage,
});
return 0;
}
return refundAmount;
}
export async function GET(request: Request, { params }: RouteParams) {
let refundContext: { userId: string; taskId: string } | null = null;
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 });
}
refundContext = { userId: session.user.id, taskId: normalizedTaskId };
const { searchParams } = new URL(request.url);
const preferredProviderId = searchParams.get("providerId");
if (normalizedTaskId.startsWith("vip98k:")) {
const [, encodedProviderId, ...remoteTaskParts] = normalizedTaskId.split(":");
const remoteTaskId = remoteTaskParts.join(":");
if (!encodedProviderId || !remoteTaskId) {
return NextResponse.json(
{ success: false, error: "VIP98K 3D taskId 格式无效" },
{ status: 400 },
);
}
const result = await getConfiguredVip98kTaskStatus({
taskId: remoteTaskId,
preferredProviderId: encodedProviderId,
});
const modelUrl =
result.outputUrls.find((url) =>
/\.(?:glb|gltf|fbx|obj|stl|3mf|usdz|ply|zip)(?:[?#].*)?$/i.test(url),
) || result.outputUrls[0] || null;
const previewImageUrl =
result.outputUrls.find((url) =>
/\.(?:png|jpe?g|webp|gif)(?:[?#].*)?$/i.test(url),
) || null;
const stableModelUrl = await archiveTripoModelIfNeeded({
taskId: remoteTaskId,
cacheKey: `vip98k:${remoteTaskId}:model`,
sourceUrl: modelUrl,
filenamePrefix: `vip98k-model-${remoteTaskId}`,
fallbackExtension: "glb",
});
const stablePreviewImageUrl = await archiveTripoModelIfNeeded({
taskId: remoteTaskId,
cacheKey: `vip98k:${remoteTaskId}:preview`,
sourceUrl: previewImageUrl,
filenamePrefix: `vip98k-preview-${remoteTaskId}`,
fallbackExtension: "jpg",
});
const errorMessage = result.failed ? "VIP98K 3D 任务执行失败" : null;
const refundedPoints = result.failed
? await refundModel3DPointsForFailedTask({
userId: session.user.id,
taskId: normalizedTaskId,
errorMessage: errorMessage || "VIP98K 3D task failed",
})
: 0;
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "model",
status: result.failed ? "failed" : result.done ? "success" : result.status || "running",
progress: result.done ? 100 : null,
output: result.payload,
modelUrl: stableModelUrl,
previewImageUrl: stablePreviewImageUrl,
assetPayload: {
providerId: result.providerId,
providerName: result.providerName,
storageArchivedAt: stableModelUrl || stablePreviewImageUrl ? new Date().toISOString() : null,
},
errorCode: result.failed ? "VIP98K_TASK_FAILED" : null,
errorMessage,
refundedPoints,
},
});
}
if (normalizedTaskId.startsWith("vip98k-direct:")) {
const [, encodedProviderId, encodedUrls] = normalizedTaskId.split(":");
if (!encodedProviderId || !encodedUrls) {
return NextResponse.json(
{ success: false, error: "VIP98K 3D direct taskId 格式无效" },
{ status: 400 },
);
}
const outputUrls = JSON.parse(
Buffer.from(encodedUrls, "base64url").toString("utf8"),
) as string[];
const modelUrl =
outputUrls.find((url) =>
/\.(?:glb|gltf|fbx|obj|stl|3mf|usdz|ply|zip)(?:[?#].*)?$/i.test(url),
) || outputUrls[0] || null;
const previewImageUrl =
outputUrls.find((url) => /\.(?:png|jpe?g|webp|gif)(?:[?#].*)?$/i.test(url)) ||
null;
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "model",
status: "success",
progress: 100,
output: { urls: outputUrls },
modelUrl,
previewImageUrl,
assetPayload: {
providerId: encodedProviderId,
providerName: "VIP98K",
storageArchivedAt: null,
},
errorCode: null,
errorMessage: null,
refundedPoints: 0,
},
});
}
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 taskStatus = String(result.data.status || "");
const errorMessage = String(result.data.error_msg || "").trim();
const refundedPoints = isFailedTripoStatus(taskStatus)
? await refundModel3DPointsForFailedTask({
userId: session.user.id,
taskId: normalizedTaskId,
errorMessage,
}).catch((refundError) => {
logger.error("points refund handler failed", refundError, {
taskId: normalizedTaskId,
userId: session.user.id,
});
return 0;
})
: 0;
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,
refundedPoints,
},
traceId: result.traceId,
});
} catch (error) {
if (error instanceof TripoApiError) {
const shouldRefundMissingTask =
error.code === 2001 || /task\s+is\s+not\s+found/i.test(error.message);
const missingTaskRefundContext = refundContext;
const refundedPoints =
shouldRefundMissingTask && missingTaskRefundContext
? await refundModel3DPointsForFailedTask({
...missingTaskRefundContext,
errorMessage: error.message,
}).catch((refundError) => {
logger.error("missing task refund failed", refundError, {
taskId: missingTaskRefundContext.taskId,
userId: missingTaskRefundContext.userId,
});
return 0;
})
: 0;
return NextResponse.json(
{
success: false,
error: error.message,
code: error.code,
suggestion: error.suggestion,
traceId: error.traceId,
refundedPoints,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询任务失败",
},
{ status: 500 }
);
}
}