Your Name fcfcbe1860
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
skip points charging for vip users
2026-03-08 01:15:30 +08:00

455 lines
14 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 {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { getTripoProviderConfig } from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
export const runtime = "nodejs";
export const maxDuration = 60;
type CreateTaskRequest = {
taskType:
| "text_to_model"
| "image_to_model"
| "multiview_to_model"
| "convert_model"
| "refine_model"
| "stylize_model"
| "texture_model"
| "animate_prerigcheck"
| "animate_rig"
| "animate_retarget"
| "mesh_segmentation"
| "mesh_completion"
| "highpoly_to_lowpoly";
prompt?: string;
imageToken?: string;
imageUrl?: string;
imageType?: string;
file?: Record<string, unknown>;
files?: Array<Record<string, unknown>>;
imageTokens?: string[];
imageUrls?: string[];
originalModelTaskId?: string;
draftModelTaskId?: string;
format?: "GLTF" | "USDZ" | "FBX" | "OBJ" | "STL" | "3MF";
style?: "lego" | "voxel" | "voronoi" | "minecraft";
blockSize?: number;
outFormat?: "glb" | "fbx";
rigType?: "biped" | "quadruped" | "hexapod" | "octopod" | "avian" | "serpentine" | "aquatic" | "others";
spec?: "mixamo" | "tripo";
animation?: string;
animations?: string[];
texturePromptText?: string;
texturePromptImageToken?: string;
texturePromptStyleImageToken?: string;
partNames?: string[];
bakeAnimation?: boolean;
exportWithGeometry?: boolean;
modelVersion?: string;
negativePrompt?: string;
faceLimit?: number;
texture?: boolean;
pbr?: boolean;
imageSeed?: number;
modelSeed?: number;
textureSeed?: number;
textureQuality?: "standard" | "detailed";
geometryQuality?: "standard" | "detailed";
textureAlignment?: "original_image" | "geometry";
autoSize?: boolean;
orientation?: "default" | "align_image";
quad?: boolean;
compress?: boolean;
generateParts?: boolean;
smartLowPoly?: boolean;
forceSymmetry?: boolean;
flattenBottom?: boolean;
flattenBottomThreshold?: number;
textureSize?: number;
textureFormat?: string;
scaleFactor?: number;
pivotToCenterBottom?: boolean;
withAnimation?: boolean;
packUv?: boolean;
bake?: boolean;
exportVertexColors?: boolean;
fbxPreset?: "blender" | "mixamo" | "3dsmax";
exportOrientation?: "+x" | "+y" | "-x" | "-y";
animateInPlace?: boolean;
};
function hasOwn(data: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(data, key);
}
function assignMapped(
payload: Record<string, unknown>,
source: Record<string, unknown>,
map: Record<string, string>
) {
for (const [sourceKey, targetKey] of Object.entries(map)) {
if (!hasOwn(source, sourceKey)) continue;
const value = source[sourceKey];
if (value === undefined || value === null) continue;
if (typeof value === "string" && !value.trim()) continue;
payload[targetKey] = value;
}
}
function shouldCharge3DPoints(taskType: CreateTaskRequest["taskType"]) {
return (
taskType === "text_to_model" ||
taskType === "image_to_model" ||
taskType === "multiview_to_model"
);
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json()) as CreateTaskRequest;
const taskType = body.taskType;
if (!taskType) {
return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 });
}
const payload: Record<string, unknown> = {
type: taskType,
};
if (taskType === "text_to_model") {
const prompt = String(body.prompt || "").trim();
if (!prompt) {
return NextResponse.json({ success: false, error: "请输入提示词" }, { status: 400 });
}
payload.prompt = prompt;
}
if (taskType === "image_to_model") {
const imageToken = String(body.imageToken || "").trim();
const imageUrl = String(body.imageUrl || "").trim();
const inputFile =
imageToken
? {
type: String(body.imageType || "jpg"),
file_token: imageToken,
}
: imageUrl
? {
type: String(body.imageType || "jpg"),
url: imageUrl,
}
: body.file;
if (!inputFile || typeof inputFile !== "object") {
return NextResponse.json(
{ success: false, error: "图生3D缺少图片输入(imageToken 或 imageUrl)" },
{ status: 400 }
);
}
payload.file = inputFile;
}
if (taskType === "multiview_to_model") {
const inputFiles: Array<Record<string, unknown>> = [];
if (Array.isArray(body.files)) {
for (const item of body.files) {
if (item && typeof item === "object") {
inputFiles.push(item);
}
}
}
if (inputFiles.length === 0 && Array.isArray(body.imageTokens)) {
for (const token of body.imageTokens) {
const normalized = String(token || "").trim();
if (!normalized) continue;
inputFiles.push({
type: String(body.imageType || "jpg"),
file_token: normalized,
});
}
}
if (inputFiles.length === 0 && Array.isArray(body.imageUrls)) {
for (const imageUrl of body.imageUrls) {
const normalized = String(imageUrl || "").trim();
if (!normalized) continue;
inputFiles.push({
type: String(body.imageType || "jpg"),
url: normalized,
});
}
}
if (inputFiles.length < 2) {
return NextResponse.json(
{ success: false, error: "多视图生成至少需要 2 张参考图" },
{ status: 400 }
);
}
payload.files = inputFiles;
}
if (taskType === "convert_model") {
const originalModelTaskId = String(body.originalModelTaskId || "").trim();
if (!originalModelTaskId) {
return NextResponse.json(
{ success: false, error: "缺少 originalModelTaskId" },
{ status: 400 }
);
}
if (!body.format) {
return NextResponse.json({ success: false, error: "缺少 format" }, { status: 400 });
}
payload.original_model_task_id = originalModelTaskId;
payload.format = body.format;
}
if (
taskType === "stylize_model" ||
taskType === "texture_model" ||
taskType === "animate_prerigcheck" ||
taskType === "animate_rig" ||
taskType === "animate_retarget" ||
taskType === "mesh_segmentation" ||
taskType === "mesh_completion" ||
taskType === "highpoly_to_lowpoly"
) {
const originalModelTaskId = String(body.originalModelTaskId || "").trim();
if (!originalModelTaskId) {
return NextResponse.json(
{ success: false, error: "缺少 originalModelTaskId" },
{ status: 400 }
);
}
payload.original_model_task_id = originalModelTaskId;
}
if (taskType === "stylize_model") {
if (!body.style) {
return NextResponse.json({ success: false, error: "缺少 style" }, { status: 400 });
}
payload.style = body.style;
}
if (taskType === "animate_retarget") {
const singleAnimation = String(body.animation || "").trim();
const animationList = Array.isArray(body.animations)
? body.animations.map((item) => String(item || "").trim()).filter(Boolean)
: [];
if (!singleAnimation && animationList.length === 0) {
return NextResponse.json(
{ success: false, error: "缺少 animation / animations" },
{ status: 400 }
);
}
if (singleAnimation) {
payload.animation = singleAnimation;
} else {
payload.animations = animationList;
}
}
if (taskType === "refine_model") {
const draftModelTaskId = String(body.draftModelTaskId || "").trim();
if (!draftModelTaskId) {
return NextResponse.json(
{ success: false, error: "缺少 draftModelTaskId" },
{ status: 400 }
);
}
payload.draft_model_task_id = draftModelTaskId;
}
if (taskType === "texture_model") {
const texturePromptText = String(body.texturePromptText || "").trim();
const texturePromptImageToken = String(body.texturePromptImageToken || "").trim();
const texturePromptStyleImageToken = String(body.texturePromptStyleImageToken || "").trim();
if (texturePromptText || texturePromptImageToken || texturePromptStyleImageToken) {
const texturePrompt: Record<string, unknown> = {};
if (texturePromptText) texturePrompt.text = texturePromptText;
if (texturePromptImageToken) {
texturePrompt.image = {
type: String(body.imageType || "jpg"),
file_token: texturePromptImageToken,
};
}
if (texturePromptStyleImageToken) {
texturePrompt.style_image = {
type: String(body.imageType || "jpg"),
file_token: texturePromptStyleImageToken,
};
}
payload.texture_prompt = texturePrompt;
}
}
assignMapped(payload, body as Record<string, unknown>, {
modelVersion: "model_version",
negativePrompt: "negative_prompt",
faceLimit: "face_limit",
texture: "texture",
pbr: "pbr",
imageSeed: "image_seed",
modelSeed: "model_seed",
textureSeed: "texture_seed",
textureQuality: "texture_quality",
geometryQuality: "geometry_quality",
textureAlignment: "texture_alignment",
autoSize: "auto_size",
orientation: "orientation",
quad: "quad",
generateParts: "generate_parts",
smartLowPoly: "smart_low_poly",
forceSymmetry: "force_symmetry",
flattenBottom: "flatten_bottom",
flattenBottomThreshold: "flatten_bottom_threshold",
textureSize: "texture_size",
textureFormat: "texture_format",
scaleFactor: "scale_factor",
blockSize: "block_size",
outFormat: "out_format",
rigType: "rig_type",
spec: "spec",
partNames: "part_names",
bakeAnimation: "bake_animation",
exportWithGeometry: "export_with_geometry",
pivotToCenterBottom: "pivot_to_center_bottom",
withAnimation: "with_animation",
packUv: "pack_uv",
bake: "bake",
exportVertexColors: "export_vertex_colors",
fbxPreset: "fbx_preset",
exportOrientation: "export_orientation",
animateInPlace: "animate_in_place",
});
if (body.compress === true) {
payload.compress = "geometry";
}
const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(taskType);
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3D))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3D)))
: 0;
if (
shouldChargePoints &&
pointsConfig.enabled &&
unitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
}
const config = await getTripoProviderConfig();
const result = await tripoRequest<Record<string, unknown>>(config, {
method: "POST",
path: "/task",
json: payload,
});
const taskId = String(result.data?.task_id || result.data?.id || "").trim();
if (!taskId) {
return NextResponse.json(
{
success: false,
error: "Tripo 返回缺少 task_id",
traceId: result.traceId,
raw: result.data,
},
{ status: 502 }
);
}
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try {
const consumeResult = await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "model3d_generation",
modelId: "tripo-3d",
note: "3D生成消耗积分",
});
if (!consumeResult.ok) {
console.warn(
`[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}`
);
} else if (consumeResult.skipped) {
console.log(
`[Points] Skipped ${unitCost} 3D points for VIP user=${session.user.id} task=${taskId}`
);
} else {
console.log(
`[Points] Consumed ${unitCost} points for 3D generation. user=${session.user.id} task=${taskId} balance=${consumeResult.balance}`
);
}
} catch (error) {
console.error("[Points] Failed to consume 3D points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: String(result.data?.status || "queued"),
raw: result.data,
},
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 : "创建 3D 任务失败",
},
{ status: 500 }
);
}
}