Your Name dfc5f704b4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
chore: sync latest app updates
2026-04-12 23:45:00 +08:00

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 });
}
}