feat: queue psd template exports
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 23:04:38 +08:00
parent 411bb4a5eb
commit 6648f43a20
9 changed files with 545 additions and 136 deletions

View File

@ -1,22 +1,19 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
import {
AGENT_PSD_RENDER_TASK_POLL_AFTER_MS,
agentPsdTemplateRenderRequestSchema,
} from "@/lib/agents/psd-render-task-contract";
import {
createAgentPsdRenderTask,
enqueueAgentPsdRenderTask,
getAgentPsdRenderTaskForUser,
} from "@/lib/agents/psd-render-task-queue";
import { describeRemoteAssetLocation } from "@/lib/agents/psd-remote-asset";
import {
type AgentPsdTemplateRenderResponse,
getAgentPsdTemplateRuntimeIssue,
getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTextLayerSlot,
type AgentPsdTemplateRenderStatus,
} from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
@ -25,24 +22,6 @@ import {
export const runtime = "nodejs";
const renderSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
imageAssignments: z.record(z.string(), z.string()).default({}),
imagePlacements: z
.record(
z.string(),
z.object({
scale: z.number().positive().max(8).optional(),
offsetX: z.number().optional(),
offsetY: z.number().optional(),
})
)
.default({}),
textAssignments: z.record(z.string(), z.string()).default({}),
textColorAssignments: z.record(z.string(), z.string()).default({}),
});
function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateRenderResponse = {
success: false,
@ -51,113 +30,102 @@ function jsonError(status: number, error: string) {
return NextResponse.json(payload, { status });
}
function normalizeTaskStatus(
status: string
): Extract<AgentPsdTemplateRenderStatus, "pending" | "running"> {
return status === "running" ? "running" : "pending";
}
export async function GET(request: NextRequest) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) {
return jsonError(400, "缺少 taskId 参数");
}
try {
const task = await getAgentPsdRenderTaskForUser({
taskId,
userId: session.user.id,
});
if (!task) {
return jsonError(404, "导出任务不存在");
}
if (task.status === "succeeded") {
const result = task.result as AgentPsdTemplateRenderResponse["result"] | null;
if (!result?.url) {
return NextResponse.json(
{
success: false,
status: "failed",
taskId: task.id,
error: "导出任务已完成,但未返回结果图",
} satisfies AgentPsdTemplateRenderResponse
);
}
return NextResponse.json({
success: true,
status: "succeeded",
taskId: task.id,
result,
} satisfies AgentPsdTemplateRenderResponse);
}
if (task.status === "failed") {
return NextResponse.json({
success: false,
status: "failed",
taskId: task.id,
error: task.error || "PSD 模板渲染失败",
} satisfies AgentPsdTemplateRenderResponse);
}
return NextResponse.json({
success: true,
processing: true,
status: normalizeTaskStatus(task.status),
taskId: task.id,
pollAfterMs: AGENT_PSD_RENDER_TASK_POLL_AFTER_MS,
} satisfies AgentPsdTemplateRenderResponse);
} catch (error) {
console.error("[PSD Templates] render status failed:", { taskId, error });
return jsonError(500, "查询 PSD 导出任务失败");
}
}
export async function POST(request: NextRequest) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
let body: z.infer<typeof renderSchema>;
let body: ReturnType<typeof agentPsdTemplateRenderRequestSchema.parse>;
try {
body = renderSchema.parse(await request.json());
body = agentPsdTemplateRenderRequestSchema.parse(await request.json());
} catch {
return jsonError(400, "渲染参数不合法");
}
try {
const templates = await readAgentPsdTemplates();
const template = templates.find(
(entry) =>
entry.id === body.templateId &&
entry.status === "active" &&
isAgentPsdTemplateReady(entry)
);
if (!template) {
return jsonError(404, "PSD 模板不存在或未启用");
}
const fonts = await readAgentPsdFonts();
const resolvedTemplate = repairAgentPsdTemplateFontBindings(template, fonts);
if (!isAgentPsdTemplateRuntimeReady(resolvedTemplate)) {
return jsonError(
409,
getAgentPsdTemplateRuntimeIssue(resolvedTemplate) ||
"PSD 模板资源不完整,请在后台重新解析后再导出"
);
}
const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate);
if (editableLayers.length === 0) {
return jsonError(400, "当前模板未配置可编辑图层");
}
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const missingFontLayer = editableLayers.find(
(layer) => isAgentPsdTextLayerSlot(layer) && layer.fontId && !fontMap.has(layer.fontId)
);
if (missingFontLayer) {
return jsonError(400, `文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
}
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
for (const layer of editableLayers) {
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
imageAssignments[layer.id] = explicitUrl;
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
continue;
}
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
imageAssignments[layer.id] = body.sourceImageUrl.trim();
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
}
}
if (isAgentPsdTextLayerSlot(layer)) {
const nextText = String(body.textAssignments[layer.id] || layer.defaultText || "").trim();
if (nextText) {
textAssignments[layer.id] = nextText;
}
textColorAssignments[layer.id] = normalizeHexColor(
body.textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
}
}
const missingRequiredLayer = editableLayers.find(
(layer) =>
layer.isRequired &&
(isAgentPsdImageLayerSlot(layer)
? !imageAssignments[layer.id]
: !textAssignments[layer.id])
);
if (missingRequiredLayer) {
return jsonError(400, `缺少必传素材:${missingRequiredLayer.layerName}`);
}
const uploaded = await renderAgentPsdTemplate({
template: resolvedTemplate,
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments,
fonts,
const task = await createAgentPsdRenderTask({
userId: session.user.id,
request: body,
});
await enqueueAgentPsdRenderTask(task.id);
return NextResponse.json({
success: true,
result: {
url: uploaded.url,
pathname: uploaded.pathname,
contentType: uploaded.contentType,
},
processing: true,
status: normalizeTaskStatus(task.status),
taskId: task.id,
pollAfterMs: AGENT_PSD_RENDER_TASK_POLL_AFTER_MS,
} satisfies AgentPsdTemplateRenderResponse);
} catch (error) {
console.error("[PSD Templates] render failed:", {

View File

@ -80,6 +80,7 @@ import {
normalizeHexColor,
resolveTextLayout,
} from "@/lib/agents/psd-text-layout";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error";
@ -2203,26 +2204,19 @@ export function AgentPsdTemplateEditorDialog({
try {
setIsRendering(true);
const response = await fetch("/api/psd-templates/render", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
setRenderedResultUrl("");
const payload = (await requestLongTaskResult({
endpoint: "/api/psd-templates/render",
timeoutMs: 8 * 60 * 1000,
payload: {
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
imageAssignments: effectiveImageAssignments,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
textColorAssignments: effectiveTextColorAssignments,
}),
});
const payload = await parseActionJsonResponse<AgentPsdTemplateRenderResponse>(
response,
"PSD 模板渲染失败",
getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试")
);
},
})) as AgentPsdTemplateRenderResponse;
if (!payload?.success || !payload.result?.url) {
throw new Error(payload?.error || "PSD 模板渲染失败");
}

View File

@ -0,0 +1,123 @@
import "server-only";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
import {
type AgentPsdTemplateRenderRequest,
} from "@/lib/agents/psd-render-task-contract";
import {
type AgentPsdTemplateRenderResult,
getAgentPsdTemplateRuntimeIssue,
getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
export async function executeAgentPsdTemplateRenderRequest(
body: AgentPsdTemplateRenderRequest
): Promise<AgentPsdTemplateRenderResult> {
const templates = await readAgentPsdTemplates();
const template = templates.find(
(entry) =>
entry.id === body.templateId &&
entry.status === "active" &&
isAgentPsdTemplateReady(entry)
);
if (!template) {
throw new Error("PSD 模板不存在或未启用");
}
const fonts = await readAgentPsdFonts();
const resolvedTemplate = repairAgentPsdTemplateFontBindings(template, fonts);
if (!isAgentPsdTemplateRuntimeReady(resolvedTemplate)) {
throw new Error(
getAgentPsdTemplateRuntimeIssue(resolvedTemplate) ||
"PSD 模板资源不完整,请在后台重新解析后再导出"
);
}
const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate);
if (editableLayers.length === 0) {
throw new Error("当前模板未配置可编辑图层");
}
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const missingFontLayer = editableLayers.find(
(layer) => isAgentPsdTextLayerSlot(layer) && layer.fontId && !fontMap.has(layer.fontId)
);
if (missingFontLayer) {
throw new Error(`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
}
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<
string,
ReturnType<typeof normalizeAgentPsdImagePlacement>
> = {};
const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
for (const layer of editableLayers) {
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
imageAssignments[layer.id] = explicitUrl;
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(
body.imagePlacements[layer.id]
);
continue;
}
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
imageAssignments[layer.id] = body.sourceImageUrl.trim();
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(
body.imagePlacements[layer.id]
);
}
}
if (isAgentPsdTextLayerSlot(layer)) {
const nextText = String(body.textAssignments[layer.id] || layer.defaultText || "").trim();
if (nextText) {
textAssignments[layer.id] = nextText;
}
textColorAssignments[layer.id] = normalizeHexColor(
body.textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
}
}
const missingRequiredLayer = editableLayers.find(
(layer) =>
layer.isRequired &&
(isAgentPsdImageLayerSlot(layer)
? !imageAssignments[layer.id]
: !textAssignments[layer.id])
);
if (missingRequiredLayer) {
throw new Error(`缺少必传素材:${missingRequiredLayer.layerName}`);
}
const uploaded = await renderAgentPsdTemplate({
template: resolvedTemplate,
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments,
fonts,
});
return {
url: uploaded.url,
pathname: uploaded.pathname,
contentType: uploaded.contentType,
};
}

View File

@ -0,0 +1,25 @@
import { z } from "zod";
export const AGENT_PSD_RENDER_TASK_POLL_AFTER_MS = 1500;
export const agentPsdTemplateRenderRequestSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
imageAssignments: z.record(z.string(), z.string()).default({}),
imagePlacements: z
.record(
z.string(),
z.object({
scale: z.number().positive().max(8).optional(),
offsetX: z.number().optional(),
offsetY: z.number().optional(),
})
)
.default({}),
textAssignments: z.record(z.string(), z.string()).default({}),
textColorAssignments: z.record(z.string(), z.string()).default({}),
});
export type AgentPsdTemplateRenderRequest = z.infer<
typeof agentPsdTemplateRenderRequestSchema
>;

View File

@ -0,0 +1,243 @@
import "server-only";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { executeAgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-service";
import type { AgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-task-contract";
import { db } from "@/lib/db";
import { agentPsdRenderTask } from "@/lib/db/schema";
type AgentPsdRenderTaskStatus = "pending" | "running" | "succeeded" | "failed";
interface QueueState {
pending: string[];
pendingSet: Set<string>;
running: Set<string>;
draining: boolean;
}
const DEFAULT_CONCURRENCY = 1;
const STALE_RUNNING_MS = 15 * 60 * 1000;
const queueStateKey = "__agent_psd_render_task_queue_state__";
function getQueueConcurrency(): number {
const raw = Number.parseInt(process.env.AGENT_PSD_RENDER_TASK_CONCURRENCY || "", 10);
if (Number.isFinite(raw) && raw > 0) {
return Math.min(raw, 3);
}
return DEFAULT_CONCURRENCY;
}
function getQueueState(): QueueState {
const globalObj = globalThis as typeof globalThis & {
[queueStateKey]?: QueueState;
};
if (!globalObj[queueStateKey]) {
globalObj[queueStateKey] = {
pending: [],
pendingSet: new Set<string>(),
running: new Set<string>(),
draining: false,
};
}
return globalObj[queueStateKey] as QueueState;
}
function enqueueLocalTask(taskId: string) {
const state = getQueueState();
if (!taskId || state.running.has(taskId) || state.pendingSet.has(taskId)) {
return;
}
state.pending.push(taskId);
state.pendingSet.add(taskId);
}
async function claimNextPendingTask(): Promise<string | null> {
const state = getQueueState();
while (state.pending.length > 0) {
const taskId = state.pending.shift();
if (!taskId) continue;
state.pendingSet.delete(taskId);
if (!state.running.has(taskId)) {
return taskId;
}
}
const runningIds = Array.from(state.running);
const whereCondition =
runningIds.length > 0
? and(eq(agentPsdRenderTask.status, "pending"), notInArray(agentPsdRenderTask.id, runningIds))
: eq(agentPsdRenderTask.status, "pending");
const rows = await db
.select({ id: agentPsdRenderTask.id })
.from(agentPsdRenderTask)
.where(whereCondition)
.orderBy(asc(agentPsdRenderTask.createdAt))
.limit(1);
return rows[0]?.id ?? null;
}
async function runSingleTask(taskId: string) {
const state = getQueueState();
state.running.add(taskId);
try {
const [task] = await db
.update(agentPsdRenderTask)
.set({
status: "running",
startedAt: new Date(),
completedAt: null,
error: null,
updatedAt: new Date(),
attempts: sql`${agentPsdRenderTask.attempts} + 1`,
})
.where(and(eq(agentPsdRenderTask.id, taskId), eq(agentPsdRenderTask.status, "pending")))
.returning({
id: agentPsdRenderTask.id,
request: agentPsdRenderTask.request,
});
if (!task) {
return;
}
const result = await executeAgentPsdTemplateRenderRequest(
task.request as AgentPsdTemplateRenderRequest
);
await db
.update(agentPsdRenderTask)
.set({
status: "succeeded",
result: result as unknown as Record<string, unknown>,
error: null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(agentPsdRenderTask.id, taskId));
} catch (error) {
await db
.update(agentPsdRenderTask)
.set({
status: "failed",
error: error instanceof Error ? error.message : String(error),
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(agentPsdRenderTask.id, taskId));
} finally {
state.running.delete(taskId);
void drainAgentPsdRenderTaskQueue();
}
}
export async function drainAgentPsdRenderTaskQueue() {
const state = getQueueState();
if (state.draining) return;
state.draining = true;
try {
const maxConcurrent = getQueueConcurrency();
while (state.running.size < maxConcurrent) {
const taskId = await claimNextPendingTask();
if (!taskId) break;
if (state.running.has(taskId)) continue;
void runSingleTask(taskId);
}
} finally {
state.draining = false;
}
}
export async function enqueueAgentPsdRenderTask(taskId: string) {
enqueueLocalTask(taskId);
await drainAgentPsdRenderTaskQueue();
}
export async function createAgentPsdRenderTask(params: {
userId: string;
request: AgentPsdTemplateRenderRequest;
}) {
const [task] = await db
.insert(agentPsdRenderTask)
.values({
userId: params.userId,
status: "pending",
request: params.request as unknown as Record<string, unknown>,
attempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({
id: agentPsdRenderTask.id,
status: agentPsdRenderTask.status,
createdAt: agentPsdRenderTask.createdAt,
});
return task;
}
export async function getAgentPsdRenderTaskForUser(params: {
taskId: string;
userId: string;
}) {
const rows = await db
.select({
id: agentPsdRenderTask.id,
status: agentPsdRenderTask.status,
request: agentPsdRenderTask.request,
result: agentPsdRenderTask.result,
error: agentPsdRenderTask.error,
attempts: agentPsdRenderTask.attempts,
startedAt: agentPsdRenderTask.startedAt,
completedAt: agentPsdRenderTask.completedAt,
createdAt: agentPsdRenderTask.createdAt,
updatedAt: agentPsdRenderTask.updatedAt,
})
.from(agentPsdRenderTask)
.where(
and(eq(agentPsdRenderTask.id, params.taskId), eq(agentPsdRenderTask.userId, params.userId))
)
.limit(1);
const task = rows[0];
if (!task) {
return null;
}
if (task.status === "running" && task.startedAt) {
const staleBefore = new Date(Date.now() - STALE_RUNNING_MS);
if (task.startedAt < staleBefore) {
await db
.update(agentPsdRenderTask)
.set({
status: "pending",
startedAt: null,
completedAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(agentPsdRenderTask.id, task.id),
eq(agentPsdRenderTask.status, "running"),
lt(agentPsdRenderTask.startedAt, staleBefore)
)
);
enqueueLocalTask(task.id);
await drainAgentPsdRenderTaskQueue();
task.status = "pending" as AgentPsdRenderTaskStatus;
task.startedAt = null;
task.completedAt = null;
}
}
if (task.status === "pending") {
enqueueLocalTask(task.id);
await drainAgentPsdRenderTaskQueue();
}
return task;
}

View File

@ -133,8 +133,18 @@ export interface AgentPsdTemplateRenderResult {
contentType?: string;
}
export type AgentPsdTemplateRenderStatus =
| "pending"
| "running"
| "succeeded"
| "failed";
export interface AgentPsdTemplateRenderResponse {
success: boolean;
processing?: boolean;
status?: AgentPsdTemplateRenderStatus;
taskId?: string;
pollAfterMs?: number;
result?: AgentPsdTemplateRenderResult;
error?: string;
}

View File

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS "AgentPsdRenderTask" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
"status" varchar(16) NOT NULL DEFAULT 'pending',
"request" jsonb NOT NULL,
"result" jsonb,
"error" text,
"attempts" integer NOT NULL DEFAULT 0,
"startedAt" timestamp,
"completedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "AgentPsdRenderTask_user_status_created_idx"
ON "AgentPsdRenderTask"("userId", "status", "createdAt");
CREATE INDEX IF NOT EXISTS "AgentPsdRenderTask_status_created_idx"
ON "AgentPsdRenderTask"("status", "createdAt");

View File

@ -379,6 +379,13 @@
"when": 1775462400000,
"tag": "0067_add_ai_cad_multi_workspace",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1775782800000,
"tag": "0068_add_agent_psd_render_tasks",
"breakpoints": true
}
]
}

View File

@ -739,6 +739,26 @@ export const canvasImageTask = pgTable("CanvasImageTask", {
export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>;
export const agentPsdRenderTask = pgTable("AgentPsdRenderTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: varchar("status", { length: 16 })
.notNull()
.default("pending"), // pending | running | succeeded | failed
request: jsonb("request").notNull(),
result: jsonb("result"),
error: text("error"),
attempts: integer("attempts").notNull().default(0),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type AgentPsdRenderTask = InferSelectModel<typeof agentPsdRenderTask>;
// 画布节点表(可选,用于更细粒度的查询)
export const canvasNode = pgTable("CanvasNode", {
id: uuid("id").primaryKey().notNull().defaultRandom(),