Your Name 434d4d0d8d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: add 3d model feature visibility and refunds
2026-05-08 11:21:15 +08:00

446 lines
13 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 {
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 }>;
}
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) {
console.error("[Points] Failed to refund failed 3D model task:", {
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");
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) => {
console.error("[Points] Failed to handle 3D task refund:", refundError);
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 refundedPoints =
shouldRefundMissingTask && refundContext
? await refundModel3DPointsForFailedTask({
...refundContext,
errorMessage: error.message,
}).catch((refundError) => {
console.error(
"[Points] Failed to refund missing 3D task:",
refundError
);
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 }
);
}
}