559 lines
17 KiB
TypeScript
559 lines
17 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|