468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
import { randomUUID } from "node:crypto";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { db } from "@/lib/db";
|
|
import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
|
import { canvasProject, model3dAsset } from "@/lib/db/schema";
|
|
|
|
type Model3DTaskStatus =
|
|
| "queued"
|
|
| "running"
|
|
| "success"
|
|
| "failed"
|
|
| "banned"
|
|
| "expired"
|
|
| "cancelled"
|
|
| "unknown";
|
|
|
|
type Model3DTaskMode =
|
|
| "text"
|
|
| "image"
|
|
| "multiview"
|
|
| "batch"
|
|
| "world-text"
|
|
| "world-image"
|
|
| "world-multi-image"
|
|
| "world-video"
|
|
| "convert"
|
|
| "refine"
|
|
| "texture"
|
|
| "stylize"
|
|
| "rigcheck"
|
|
| "rig"
|
|
| "retarget"
|
|
| "segment"
|
|
| "complete"
|
|
| "lowpoly";
|
|
|
|
const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
|
"queued",
|
|
"running",
|
|
"success",
|
|
"failed",
|
|
"banned",
|
|
"expired",
|
|
"cancelled",
|
|
"unknown",
|
|
]);
|
|
|
|
const TASK_MODE_SET = new Set<Model3DTaskMode>([
|
|
"text",
|
|
"image",
|
|
"multiview",
|
|
"batch",
|
|
"world-text",
|
|
"world-image",
|
|
"world-multi-image",
|
|
"world-video",
|
|
"convert",
|
|
"refine",
|
|
"texture",
|
|
"stylize",
|
|
"rigcheck",
|
|
"rig",
|
|
"retarget",
|
|
"segment",
|
|
"complete",
|
|
"lowpoly",
|
|
]);
|
|
|
|
const TASK_LIST_LIMIT = 120;
|
|
|
|
function normalizeTaskStatus(input: unknown): Model3DTaskStatus {
|
|
const value = String(input || "").trim() as Model3DTaskStatus;
|
|
return FINAL_TASK_STATUS.has(value) ? value : "unknown";
|
|
}
|
|
|
|
function normalizeTaskMode(input: unknown): Model3DTaskMode {
|
|
const value = String(input || "").trim() as Model3DTaskMode;
|
|
return TASK_MODE_SET.has(value) ? value : "text";
|
|
}
|
|
|
|
function readOptionalText(input: unknown): string | null {
|
|
const normalized = String(input || "").trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function readProvider(input: unknown): "tripo-3d" | "worldlabs-3d" {
|
|
return String(input || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d";
|
|
}
|
|
|
|
function readJsonObject(input: unknown): Record<string, unknown> | null {
|
|
return input && typeof input === "object" && !Array.isArray(input)
|
|
? (input as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function normalizeAssetPayloadModelAlias(
|
|
payload: unknown,
|
|
entries: Awaited<
|
|
ReturnType<typeof getThreeDModelAliasConfigForRuntime>
|
|
>["worldModels"],
|
|
) {
|
|
const objectValue = readJsonObject(payload);
|
|
if (!objectValue) return objectValue;
|
|
|
|
const nextModel = resolveThreeDModelAliasId(objectValue.model, entries);
|
|
return {
|
|
...objectValue,
|
|
...(nextModel ? { model: nextModel } : {}),
|
|
};
|
|
}
|
|
|
|
function readProgress(input: unknown): number | null {
|
|
if (input === undefined || input === null || input === "") return null;
|
|
const value = Number(input);
|
|
if (!Number.isFinite(value)) return null;
|
|
return Math.max(0, Math.min(100, Math.round(value)));
|
|
}
|
|
|
|
function createShareToken() {
|
|
return randomUUID().replace(/-/g, "");
|
|
}
|
|
|
|
function readDate(input: unknown): Date | null {
|
|
if (!input) return null;
|
|
if (input instanceof Date && !Number.isNaN(input.getTime())) return input;
|
|
if (typeof input === "number" && Number.isFinite(input)) {
|
|
const fromNumber = new Date(input);
|
|
if (!Number.isNaN(fromNumber.getTime())) return fromNumber;
|
|
}
|
|
if (typeof input === "string" && input.trim()) {
|
|
const fromText = new Date(input);
|
|
if (!Number.isNaN(fromText.getTime())) return fromText;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function ensureProjectOwnership(projectId: string, userId: string) {
|
|
const [project] = await db
|
|
.select({ id: canvasProject.id })
|
|
.from(canvasProject)
|
|
.where(and(eq(canvasProject.id, projectId), eq(canvasProject.userId, userId)))
|
|
.limit(1);
|
|
return Boolean(project);
|
|
}
|
|
|
|
export async function GET(
|
|
_request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const { id: projectId } = await params;
|
|
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
|
|
if (!ownsProject) {
|
|
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
|
|
}
|
|
|
|
const assets = await db
|
|
.select()
|
|
.from(model3dAsset)
|
|
.where(
|
|
and(
|
|
eq(model3dAsset.projectId, projectId),
|
|
eq(model3dAsset.userId, session.user.id)
|
|
)
|
|
)
|
|
.orderBy(desc(model3dAsset.updatedAt))
|
|
.limit(TASK_LIST_LIMIT);
|
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
|
const normalizedAssets = assets.map((asset) => {
|
|
const entries =
|
|
asset.provider === "worldlabs-3d"
|
|
? modelAliasConfig.worldModels
|
|
: modelAliasConfig.tripoModels;
|
|
|
|
return {
|
|
...asset,
|
|
modelVersion: resolveThreeDModelAliasId(asset.modelVersion, entries),
|
|
assetPayload:
|
|
asset.provider === "worldlabs-3d"
|
|
? normalizeAssetPayloadModelAlias(
|
|
asset.assetPayload,
|
|
modelAliasConfig.worldModels,
|
|
)
|
|
: asset.assetPayload,
|
|
};
|
|
});
|
|
|
|
return NextResponse.json({ success: true, data: normalizedAssets });
|
|
} catch (error) {
|
|
console.error("[Model3DAssets] GET failed:", error);
|
|
return NextResponse.json({ success: false, error: "获取 3D 资产失败" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function POST(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const { id: projectId } = await params;
|
|
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
|
|
if (!ownsProject) {
|
|
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
|
|
}
|
|
|
|
const body = await request.json().catch(() => ({}));
|
|
const rawAsset = ((body as any)?.asset || body || {}) as Record<string, unknown>;
|
|
const taskId = String(rawAsset.taskId || "").trim();
|
|
if (!taskId) {
|
|
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
|
}
|
|
|
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
|
const status = normalizeTaskStatus(rawAsset.status);
|
|
const mode = normalizeTaskMode(rawAsset.mode);
|
|
const provider = readProvider(rawAsset.provider);
|
|
const prompt = String(rawAsset.prompt || "").trim();
|
|
const modelVersion = resolveThreeDModelAliasId(
|
|
rawAsset.modelVersion,
|
|
provider === "worldlabs-3d"
|
|
? modelAliasConfig.worldModels
|
|
: modelAliasConfig.tripoModels,
|
|
);
|
|
const progress = readProgress(rawAsset.progress);
|
|
const modelUrl = readOptionalText(rawAsset.modelUrl);
|
|
const previewImageUrl = readOptionalText(rawAsset.previewImageUrl);
|
|
const error = readOptionalText(rawAsset.error);
|
|
const sourceTaskId = readOptionalText(rawAsset.sourceTaskId);
|
|
const exportFormat = readOptionalText(rawAsset.exportFormat);
|
|
const assetPayload =
|
|
provider === "worldlabs-3d"
|
|
? normalizeAssetPayloadModelAlias(
|
|
rawAsset.assetPayload,
|
|
modelAliasConfig.worldModels,
|
|
)
|
|
: readJsonObject(rawAsset.assetPayload);
|
|
const createdAt = readDate(rawAsset.createdAt);
|
|
|
|
const now = new Date();
|
|
const [saved] = await db
|
|
.insert(model3dAsset)
|
|
.values({
|
|
userId: session.user.id,
|
|
projectId,
|
|
taskId,
|
|
provider,
|
|
status,
|
|
mode,
|
|
prompt,
|
|
modelVersion,
|
|
progress,
|
|
modelUrl,
|
|
previewImageUrl,
|
|
error,
|
|
sourceTaskId,
|
|
exportFormat,
|
|
assetPayload,
|
|
createdAt: createdAt || now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [model3dAsset.projectId, model3dAsset.taskId],
|
|
set: {
|
|
userId: session.user.id,
|
|
provider,
|
|
status,
|
|
mode,
|
|
prompt,
|
|
modelVersion,
|
|
progress,
|
|
modelUrl,
|
|
previewImageUrl,
|
|
error,
|
|
sourceTaskId,
|
|
exportFormat,
|
|
assetPayload,
|
|
updatedAt: now,
|
|
},
|
|
})
|
|
.returning();
|
|
|
|
return NextResponse.json({ success: true, data: saved });
|
|
} catch (error) {
|
|
console.error("[Model3DAssets] POST failed:", error);
|
|
return NextResponse.json({ success: false, error: "保存 3D 资产失败" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function DELETE(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const { id: projectId } = await params;
|
|
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
|
|
if (!ownsProject) {
|
|
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
|
|
}
|
|
|
|
const searchTaskId = request.nextUrl.searchParams.get("taskId");
|
|
let bodyTaskId = "";
|
|
if (!searchTaskId) {
|
|
const body = await request.json().catch(() => ({}));
|
|
bodyTaskId = String((body as any)?.taskId || "").trim();
|
|
}
|
|
const taskId = String(searchTaskId || bodyTaskId || "").trim();
|
|
|
|
const conditions = [
|
|
eq(model3dAsset.projectId, projectId),
|
|
eq(model3dAsset.userId, session.user.id),
|
|
];
|
|
if (taskId) {
|
|
conditions.push(eq(model3dAsset.taskId, taskId));
|
|
}
|
|
|
|
const deleted = await db
|
|
.delete(model3dAsset)
|
|
.where(and(...conditions))
|
|
.returning({ id: model3dAsset.id });
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: { deletedCount: deleted.length },
|
|
});
|
|
} catch (error) {
|
|
console.error("[Model3DAssets] DELETE failed:", error);
|
|
return NextResponse.json({ success: false, error: "清理 3D 资产失败" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function PATCH(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const { id: projectId } = await params;
|
|
const ownsProject = await ensureProjectOwnership(projectId, session.user.id);
|
|
if (!ownsProject) {
|
|
return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 });
|
|
}
|
|
|
|
const body = await request.json().catch(() => ({}));
|
|
const rawTaskIds = Array.isArray((body as any)?.taskIds)
|
|
? (body as any).taskIds
|
|
: [(body as any)?.taskId];
|
|
const taskIds = Array.from(
|
|
new Set<string>(
|
|
rawTaskIds
|
|
.map((value: unknown) => String(value || "").trim())
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
if (taskIds.length === 0) {
|
|
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
|
}
|
|
if (taskIds.length > 30) {
|
|
return NextResponse.json(
|
|
{ success: false, error: "一次最多分享 30 个 3D 世界" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const existingAssets = await db
|
|
.select({
|
|
id: model3dAsset.id,
|
|
taskId: model3dAsset.taskId,
|
|
provider: model3dAsset.provider,
|
|
status: model3dAsset.status,
|
|
shareEnabled: model3dAsset.shareEnabled,
|
|
shareToken: model3dAsset.shareToken,
|
|
})
|
|
.from(model3dAsset)
|
|
.where(
|
|
and(
|
|
eq(model3dAsset.projectId, projectId),
|
|
eq(model3dAsset.userId, session.user.id),
|
|
inArray(model3dAsset.taskId, taskIds)
|
|
)
|
|
);
|
|
|
|
if (existingAssets.length !== taskIds.length) {
|
|
return NextResponse.json({ success: false, error: "资产不存在" }, { status: 404 });
|
|
}
|
|
|
|
if (existingAssets.some((asset) => asset.provider !== "worldlabs-3d")) {
|
|
return NextResponse.json(
|
|
{ success: false, error: "当前仅支持分享 3D 世界资产" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
if (existingAssets.some((asset) => asset.status !== "success")) {
|
|
return NextResponse.json(
|
|
{ success: false, error: "仅成功生成的 3D 世界支持分享" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const shouldEnable = (body as any)?.shareEnabled !== false;
|
|
const existingShareToken = existingAssets.find(
|
|
(asset) => asset.shareEnabled && asset.shareToken
|
|
)?.shareToken;
|
|
const shareToken =
|
|
shouldEnable
|
|
? taskIds.length > 1
|
|
? existingShareToken || createShareToken()
|
|
: existingAssets[0]?.shareToken || createShareToken()
|
|
: null;
|
|
const nextValues = shouldEnable
|
|
? {
|
|
shareEnabled: true,
|
|
shareToken,
|
|
sharedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
: {
|
|
shareEnabled: false,
|
|
shareToken: null,
|
|
sharedAt: null,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const updatedAssets = await db
|
|
.update(model3dAsset)
|
|
.set(nextValues)
|
|
.where(inArray(model3dAsset.id, existingAssets.map((asset) => asset.id)))
|
|
.returning();
|
|
const updatedAssetByTaskId = new Map(
|
|
updatedAssets.map((asset) => [asset.taskId, asset])
|
|
);
|
|
const orderedUpdatedAssets = taskIds
|
|
.map((id) => updatedAssetByTaskId.get(id))
|
|
.filter((asset): asset is (typeof updatedAssets)[number] => Boolean(asset));
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: orderedUpdatedAssets[0] || updatedAssets[0] || null,
|
|
items: orderedUpdatedAssets,
|
|
shareToken,
|
|
});
|
|
} catch (error) {
|
|
console.error("[Model3DAssets] PATCH failed:", error);
|
|
return NextResponse.json({ success: false, error: "更新 3D 世界分享状态失败" }, { status: 500 });
|
|
}
|
|
}
|