202 lines
6.6 KiB
TypeScript
202 lines
6.6 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
||
import { NextResponse } from "next/server";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import {
|
||
CANVAS_LAYOUT_REPLACE_BATCH_LIMIT,
|
||
createCanvasLayoutReplaceBatch,
|
||
getCanvasLayoutReplaceBatchActiveTaskId,
|
||
getLatestCanvasLayoutReplaceBatchForUser,
|
||
type CanvasLayoutReplaceBatchSource,
|
||
} from "@/lib/canvas/layout-replace-batch";
|
||
import {
|
||
enqueueCanvasImageTask,
|
||
ensureCanvasImageTaskActive,
|
||
} from "@/lib/canvas/image-task-queue";
|
||
import { db } from "@/lib/db";
|
||
import { canvasProject } from "@/lib/db/schema";
|
||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||
|
||
export const maxDuration = 1200;
|
||
|
||
function normalizeHttpUrl(value: unknown) {
|
||
const url = String(value || "").trim();
|
||
return /^https?:\/\//i.test(url) ? url : "";
|
||
}
|
||
|
||
function normalizeUuid(value: unknown) {
|
||
const id = String(value || "").trim();
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||
? id
|
||
: "";
|
||
}
|
||
|
||
function normalizeSources(value: unknown): CanvasLayoutReplaceBatchSource[] {
|
||
if (!Array.isArray(value)) return [];
|
||
const seen = new Set<string>();
|
||
const sources: CanvasLayoutReplaceBatchSource[] = [];
|
||
for (const item of value) {
|
||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||
const record = item as Record<string, unknown>;
|
||
const sourceImageUrl = normalizeHttpUrl(record.sourceImageUrl);
|
||
if (!sourceImageUrl || seen.has(sourceImageUrl)) continue;
|
||
seen.add(sourceImageUrl);
|
||
sources.push({
|
||
sourceImageUrl,
|
||
sourceKind: record.sourceKind === "upload" ? "upload" : "canvas",
|
||
sourceNodeId: String(record.sourceNodeId || "").trim() || undefined,
|
||
sourceTitle: String(record.sourceTitle || "").trim().slice(0, 256) || undefined,
|
||
});
|
||
if (sources.length >= CANVAS_LAYOUT_REPLACE_BATCH_LIMIT) break;
|
||
}
|
||
return sources;
|
||
}
|
||
|
||
async function getOwnedProject(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 project || null;
|
||
}
|
||
|
||
export async function GET(request: Request) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
const { searchParams } = new URL(request.url);
|
||
const projectId = normalizeUuid(searchParams.get("projectId"));
|
||
const sourceNodeId = String(searchParams.get("sourceNodeId") || "").trim();
|
||
if (!projectId) {
|
||
return NextResponse.json(
|
||
{ success: false, error: "缺少有效的画布项目" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
if (!(await getOwnedProject(projectId, session.user.id))) {
|
||
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
|
||
}
|
||
|
||
let batch = await getLatestCanvasLayoutReplaceBatchForUser({
|
||
projectId,
|
||
userId: session.user.id,
|
||
sourceNodeId: sourceNodeId || undefined,
|
||
});
|
||
const activeTaskId = batch
|
||
? getCanvasLayoutReplaceBatchActiveTaskId(batch)
|
||
: null;
|
||
if (activeTaskId) {
|
||
await ensureCanvasImageTaskActive({
|
||
taskId: activeTaskId,
|
||
userId: session.user.id,
|
||
});
|
||
batch = await getLatestCanvasLayoutReplaceBatchForUser({
|
||
projectId,
|
||
userId: session.user.id,
|
||
sourceNodeId: sourceNodeId || undefined,
|
||
});
|
||
}
|
||
|
||
return NextResponse.json({ success: true, batch: batch || null });
|
||
} catch (error) {
|
||
console.error("[Canvas Layout Replace Batch][GET] Error:", error);
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "查询批次失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
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().catch(() => ({}));
|
||
const projectId = normalizeUuid(body?.projectId);
|
||
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
|
||
const sources = normalizeSources(body?.sources);
|
||
if (!projectId || !(await getOwnedProject(projectId, session.user.id))) {
|
||
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
|
||
}
|
||
if (sources.length === 0) {
|
||
return NextResponse.json(
|
||
{ success: false, error: "请至少添加一张待处理图片" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
if (!referenceImageUrl) {
|
||
return NextResponse.json(
|
||
{ success: false, error: "请上传目标模版图" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const pointsConfig = await getPointsSystemConfig();
|
||
const perImageCost = getImageToolCreditCost(pointsConfig, "repaint");
|
||
const totalCost = perImageCost * sources.length;
|
||
const pointsCharge = await prepareFeaturePointsCharge({
|
||
userId: session.user.id,
|
||
unitCost: totalCost,
|
||
reason: "image_repaint",
|
||
modelId: "canvas-layout-replace-batch",
|
||
note: `批量排版替换 ${sources.length} 张`,
|
||
pointsConfig,
|
||
});
|
||
if (!pointsCharge.ok) {
|
||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||
}
|
||
|
||
const created = await createCanvasLayoutReplaceBatch({
|
||
userId: session.user.id,
|
||
projectId,
|
||
sources,
|
||
referenceImageUrl,
|
||
supplementalPrompt:
|
||
typeof body?.supplementalPrompt === "string"
|
||
? body.supplementalPrompt
|
||
: undefined,
|
||
imageSize: typeof body?.imageSize === "string" ? body.imageSize : "2K",
|
||
model: typeof body?.model === "string" ? body.model : undefined,
|
||
});
|
||
await enqueueCanvasImageTask(created.firstTaskId);
|
||
|
||
if (pointsCharge.chargeable) {
|
||
try {
|
||
await pointsCharge.consume({
|
||
note: `批量排版替换 ${sources.length} 张(${created.batchId})`,
|
||
});
|
||
} catch (error) {
|
||
console.error("[Points] Failed to consume layout batch points:", error);
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
batchId: created.batchId,
|
||
firstTaskId: created.firstTaskId,
|
||
sourceCount: sources.length,
|
||
pointsCost: totalCost,
|
||
});
|
||
} catch (error) {
|
||
console.error("[Canvas Layout Replace Batch][POST] Error:", error);
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "创建批次失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|