From db469340251c956a55fa3640a4fba929a3e009df Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 10 Apr 2026 21:03:45 +0800 Subject: [PATCH] fix: stabilize PSD template parsing workflow --- .../psd-templates-settings-client.tsx | 37 ++++++- .../admin/psd-templates/[id]/parse/route.ts | 76 +++++++++++--- app/api/admin/psd-templates/[id]/route.ts | 5 + app/api/admin/psd-templates/route.ts | 37 +++---- lib/agents/psd-local-cache.ts | 99 +++++++++++++++++++ lib/agents/psd-template-parser.ts | 89 ++++++++++++++++- lib/agents/psd-template-repair.ts | 16 ++- lib/agents/psd-template-settings.ts | 6 ++ lib/agents/psd-template-style-resolver.ts | 11 ++- 9 files changed, 331 insertions(+), 45 deletions(-) create mode 100644 lib/agents/psd-local-cache.ts diff --git a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx index b2a03d42f..b365ac17b 100644 --- a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx @@ -227,7 +227,10 @@ export function PsdTemplatesSettingsClient({ backHref: string; backLabel: string; }) { - const { data, mutate } = useSWR("/api/admin/psd-templates", fetcher); + const { data, mutate, isLoading } = useSWR( + "/api/admin/psd-templates", + fetcher + ); const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<{ agentWorkflowUiConfig?: AgentWorkflowUiConfig; agentPsdTemplateCategoryTagConfig?: { @@ -240,6 +243,12 @@ export function PsdTemplatesSettingsClient({ ); const templates = useMemo(() => (data?.success ? data.templates : []), [data]); + const templateListErrorMessage = useMemo(() => { + if (!data || data.success) { + return ""; + } + return String(data.error || "").trim() || "PSD 模板读取失败"; + }, [data]); const fonts = useMemo(() => (fontsData?.success ? fontsData.fonts : []), [fontsData]); const brandOptions = useMemo( () => @@ -1712,7 +1721,31 @@ export function PsdTemplatesSettingsClient({ 按品牌查看模板,选择一个模板继续配置槽位。 - {templates.length === 0 ? ( + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ 正在加载 PSD 模板列表... +
+ ))} +
+ ) : templateListErrorMessage ? ( +
+
{templateListErrorMessage}
+ +
+ ) : templates.length === 0 ? (
还没有 PSD 模板,先上传一个模板开始配置。
diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts index fa18c2c3f..c16d0722d 100644 --- a/app/api/admin/psd-templates/[id]/parse/route.ts +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -16,6 +16,12 @@ import { preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection"; +import { + cacheAgentPsdSourceLocally, + hasAgentPsdLocalCache, + readAgentPsdSourceFromLocalCache, + resolveAgentPsdLocalCachePath, +} from "@/lib/agents/psd-local-cache"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; import { uploadFile } from "@/lib/storage/service"; @@ -223,24 +229,64 @@ export async function POST( error: undefined, }); - const response = await fetch(currentTemplate.fileUrl, { - cache: "no-store", - signal: AbortSignal.timeout(60_000), + const localFilePath = resolveAgentPsdLocalCachePath({ + templateId: id, + fileName: currentTemplate.fileName, }); - if (!response.ok) { - throw new Error(`PSD 文件下载失败 (${response.status})`); + let fileBuffer: ArrayBuffer; + if ( + await hasAgentPsdLocalCache({ + templateId: id, + fileName: currentTemplate.fileName, + }) + ) { + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 18, + stage: "正在读取服务器本地 PSD 缓存", + error: undefined, + }); + const localBuffer = await readAgentPsdSourceFromLocalCache({ + templateId: id, + fileName: currentTemplate.fileName, + }); + fileBuffer = localBuffer.buffer.slice( + localBuffer.byteOffset, + localBuffer.byteOffset + localBuffer.byteLength + ) as ArrayBuffer; + } else { + const response = await fetch(currentTemplate.fileUrl, { + cache: "no-store", + signal: AbortSignal.timeout(60_000), + }); + + if (!response.ok) { + throw new Error(`PSD 文件下载失败 (${response.status})`); + } + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 18, + stage: "正在下载并写入服务器本地 PSD 缓存", + error: undefined, + }); + + fileBuffer = await response.arrayBuffer(); + await cacheAgentPsdSourceLocally({ + templateId: id, + fileName: currentTemplate.fileName, + buffer: fileBuffer, + }).catch((error) => { + console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", { + templateId: id, + error, + }); + }); } - await updateParseProgress({ - templateId: id, - updatedBy: session.user.id, - progress: 18, - stage: "正在读取 PSD 文件", - error: undefined, - }); - - const fileBuffer = await response.arrayBuffer(); const parsedTemplate = await parsePsdTemplateFile(fileBuffer); if (parsedTemplate.layers.length === 0) { @@ -287,6 +333,8 @@ export async function POST( const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: id, fileUrl: currentTemplate.fileUrl, + filePath: localFilePath, + fileBuffer, layers: initialLayers, }); diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts index d33e9bc0e..15c2506df 100644 --- a/app/api/admin/psd-templates/[id]/route.ts +++ b/app/api/admin/psd-templates/[id]/route.ts @@ -14,6 +14,7 @@ import { type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config"; +import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; @@ -198,6 +199,10 @@ export async function PUT( const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: currentTemplate.id, fileUrl: currentTemplate.fileUrl, + filePath: resolveAgentPsdLocalCachePath({ + templateId: currentTemplate.id, + fileName: currentTemplate.fileName, + }), layers: nextLayers, }); nextLayers = preparedAssets.layers; diff --git a/app/api/admin/psd-templates/route.ts b/app/api/admin/psd-templates/route.ts index bfeae87b4..699667f22 100644 --- a/app/api/admin/psd-templates/route.ts +++ b/app/api/admin/psd-templates/route.ts @@ -4,8 +4,8 @@ import { upsertSystemSetting } from "@/lib/db/queries"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { uploadFile } from "@/lib/storage/service"; import { - AGENT_PSD_TEMPLATES_KEY, AGENT_PSD_TEMPLATE_ASSET_VERSION, + AGENT_PSD_TEMPLATES_KEY, normalizeAgentPsdTemplateMode, normalizeAgentPsdTemplateStatus, upsertAgentPsdTemplate, @@ -15,7 +15,7 @@ import { type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config"; -import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; +import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; export const runtime = "nodejs"; @@ -67,28 +67,9 @@ export async function GET() { try { const templates = await readAgentPsdTemplates(); - const repairedTemplates = [] as AgentPsdTemplate[]; - let hasRepairedTemplate = false; - - for (const template of templates) { - const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template); - repairedTemplates.push(repairedTemplate); - if (repairedTemplate !== template) { - hasRepairedTemplate = true; - } - } - - if (hasRepairedTemplate) { - await upsertSystemSetting({ - key: AGENT_PSD_TEMPLATES_KEY, - value: repairedTemplates, - updatedBy: session.user.id, - }); - } - return NextResponse.json({ success: true, - templates: hasRepairedTemplate ? repairedTemplates : templates, + templates, } satisfies AgentPsdTemplateListResponse); } catch (error) { console.error("[PSD Templates][Admin] list failed:", error); @@ -161,6 +142,17 @@ export async function POST(request: NextRequest) { try { const fileBuffer = await file.arrayBuffer(); + const templateId = randomUUID(); + await cacheAgentPsdSourceLocally({ + templateId, + fileName: file.name, + buffer: fileBuffer, + }).catch((error) => { + console.warn("[PSD Templates][Admin] failed to cache uploaded PSD locally:", { + templateId, + error, + }); + }); const [uploadedFile, uploadedPreview] = await Promise.all([ uploadFile(file.name, fileBuffer), uploadFile( @@ -169,7 +161,6 @@ export async function POST(request: NextRequest) { ), ]); - const templateId = randomUUID(); const now = new Date().toISOString(); const nextTemplate: AgentPsdTemplate = { id: templateId, diff --git a/lib/agents/psd-local-cache.ts b/lib/agents/psd-local-cache.ts new file mode 100644 index 000000000..ee919ddf2 --- /dev/null +++ b/lib/agents/psd-local-cache.ts @@ -0,0 +1,99 @@ +import "server-only"; + +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; + +const PSD_LOCAL_CACHE_ROOT = path.join( + process.env.AGENT_PSD_LOCAL_CACHE_DIR || os.tmpdir(), + "agent-psd-cache" +); + +function sanitizePathSegment(value: string, fallback: string) { + const normalized = String(value || "") + .trim() + .replace(/[^a-zA-Z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || fallback; +} + +function normalizeTemplateExtension(fileName: string) { + const normalized = String(fileName || "").trim().toLowerCase(); + if (normalized.endsWith(".psb")) { + return "psb"; + } + return "psd"; +} + +function toBuffer(value: ArrayBuffer | Buffer) { + if (Buffer.isBuffer(value)) { + return value; + } + + return Buffer.from(value); +} + +export function resolveAgentPsdLocalCachePath(params: { + templateId: string; + fileName?: string | null; +}) { + const templateId = sanitizePathSegment(params.templateId, "template"); + const extension = normalizeTemplateExtension(String(params.fileName || "")); + return path.join(PSD_LOCAL_CACHE_ROOT, `${templateId}.${extension}`); +} + +export async function cacheAgentPsdSourceLocally(params: { + templateId: string; + fileName?: string | null; + buffer: ArrayBuffer | Buffer; +}) { + const localFilePath = resolveAgentPsdLocalCachePath({ + templateId: params.templateId, + fileName: params.fileName, + }); + await fs.mkdir(path.dirname(localFilePath), { recursive: true }); + await fs.writeFile(localFilePath, toBuffer(params.buffer)); + return localFilePath; +} + +export async function readAgentPsdSourceFromLocalCache(params: { + templateId: string; + fileName?: string | null; +}) { + return fs.readFile( + resolveAgentPsdLocalCachePath({ + templateId: params.templateId, + fileName: params.fileName, + }) + ); +} + +export async function hasAgentPsdLocalCache(params: { + templateId: string; + fileName?: string | null; +}) { + try { + await fs.access( + resolveAgentPsdLocalCachePath({ + templateId: params.templateId, + fileName: params.fileName, + }) + ); + return true; + } catch { + return false; + } +} + +export async function deleteAgentPsdLocalCache(params: { + templateId: string; + fileName?: string | null; +}) { + const localFilePath = resolveAgentPsdLocalCachePath({ + templateId: params.templateId, + fileName: params.fileName, + }); + await fs.rm(localFilePath, { force: true }); +} diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index f4ac330ed..0fe81933f 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -1,5 +1,6 @@ import "server-only"; +import { promises as fs } from "node:fs"; import { uploadFile } from "@/lib/storage/service"; import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching"; import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset"; @@ -447,6 +448,54 @@ async function fetchRemoteBuffer(url: string) { ) as ArrayBuffer; } +function toSourceArrayBuffer(value: ArrayBuffer | Buffer) { + if (Buffer.isBuffer(value)) { + return value.buffer.slice( + value.byteOffset, + value.byteOffset + value.byteLength + ) as ArrayBuffer; + } + + return value; +} + +async function readLocalPsdSourceBuffer(filePath: string) { + const buffer = await fs.readFile(filePath); + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +async function resolvePsdSourceBuffer(params: { + fileUrl?: string; + filePath?: string; + fileBuffer?: ArrayBuffer | Buffer; +}) { + if (params.fileBuffer) { + return toSourceArrayBuffer(params.fileBuffer); + } + + const localFilePath = String(params.filePath || "").trim(); + if (localFilePath) { + try { + return await readLocalPsdSourceBuffer(localFilePath); + } catch (error) { + console.warn("[PSD Template] failed to read local PSD cache, falling back:", { + filePath: localFilePath, + error, + }); + } + } + + const fileUrl = String(params.fileUrl || "").trim(); + if (fileUrl) { + return fetchRemoteBuffer(fileUrl); + } + + throw new Error("PSD 模板源文件不可用"); +} + export async function parsePsdTemplateFile( buffer: ArrayBuffer ): Promise { @@ -472,6 +521,15 @@ export async function parsePsdTemplateFileFromUrl(fileUrl: string) { return parsePsdTemplateFile(buffer); } +export async function parsePsdTemplateFileFromSource(params: { + fileUrl?: string; + filePath?: string; + fileBuffer?: ArrayBuffer | Buffer; +}) { + const buffer = await resolvePsdSourceBuffer(params); + return parsePsdTemplateFile(buffer); +} + export async function loadPsdTemplateSourceFromUrl(fileUrl: string) { const buffer = await fetchRemoteBuffer(fileUrl); const psd = await parsePsdBuffer(buffer); @@ -489,6 +547,27 @@ export async function loadPsdTemplateSourceFromUrl(fileUrl: string) { }; } +export async function loadPsdTemplateSource(params: { + fileUrl?: string; + filePath?: string; + fileBuffer?: ArrayBuffer | Buffer; +}) { + const buffer = await resolvePsdSourceBuffer(params); + const psd = await parsePsdBuffer(buffer); + const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); + + return { + width: Math.max(1, Math.round(Number(psd.width || 0))), + height: Math.max(1, Math.round(Number(psd.height || 0))), + layers: layerEntries + .filter((entry) => entry.node && typeof entry.node.composite === "function") + .map((entry) => ({ + layer: entry.layer, + composite: () => entry.node.composite!(), + })) satisfies PsdTemplateSourceLayerEntry[], + }; +} + async function createTransparentCanvasBuffer(width: number, height: number) { const sharp = await getSharp(); const canvasWidth = Math.max(1, Math.round(Number(width || 0))); @@ -577,11 +656,17 @@ async function fitOverlayBufferIntoCanvas(params: { export async function preparePsdTemplateRenderAssets(params: { templateId: string; - fileUrl: string; + fileUrl?: string; + filePath?: string; + fileBuffer?: ArrayBuffer | Buffer; layers: AgentPsdTemplateLayerSlot[]; forceRefreshLayerAssets?: boolean; }) { - const psdBuffer = await fetchRemoteBuffer(params.fileUrl); + const psdBuffer = await resolvePsdSourceBuffer({ + fileUrl: params.fileUrl, + filePath: params.filePath, + fileBuffer: params.fileBuffer, + }); const psd = await parsePsdBuffer(psdBuffer); const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []); const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry])); diff --git a/lib/agents/psd-template-repair.ts b/lib/agents/psd-template-repair.ts index 98aa6f654..0774c9733 100644 --- a/lib/agents/psd-template-repair.ts +++ b/lib/agents/psd-template-repair.ts @@ -17,9 +17,10 @@ import { pickPrimaryPsdImageLayerId, } from "@/lib/agents/psd-template-image-slot-selection"; import { - parsePsdTemplateFileFromUrl, + parsePsdTemplateFileFromSource, preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; +import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { uploadFile } from "@/lib/storage/service"; function hasMissingLayerAssets(template: AgentPsdTemplate) { @@ -209,7 +210,14 @@ export async function repairAgentPsdTemplateAssetsIfNeeded( let nextLayers = repairEditableImageLayerSelection(template); if (needsFontDetection) { try { - const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl); + const localFilePath = resolveAgentPsdLocalCachePath({ + templateId: template.id, + fileName: template.fileName, + }); + const parsedTemplate = await parsePsdTemplateFileFromSource({ + fileUrl: template.fileUrl, + filePath: localFilePath, + }); const parsedTextLayerMap = new Map( parsedTemplate.layers .filter((layer) => layer.slotType === "text") @@ -260,6 +268,10 @@ export async function repairAgentPsdTemplateAssetsIfNeeded( const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: template.id, fileUrl: template.fileUrl, + filePath: resolveAgentPsdLocalCachePath({ + templateId: template.id, + fileName: template.fileName, + }), layers: nextLayers, forceRefreshLayerAssets: needsAssetVersionRefresh, }); diff --git a/lib/agents/psd-template-settings.ts b/lib/agents/psd-template-settings.ts index 351b68fe4..f2866c0ea 100644 --- a/lib/agents/psd-template-settings.ts +++ b/lib/agents/psd-template-settings.ts @@ -7,6 +7,7 @@ import { parseAgentPsdTemplates, type AgentPsdTemplate, } from "@/lib/agents/psd-template-config"; +import { deleteAgentPsdLocalCache } from "@/lib/agents/psd-local-cache"; export async function readAgentPsdTemplates() { const setting = await getSystemSetting({ key: AGENT_PSD_TEMPLATES_KEY }); @@ -14,6 +15,11 @@ export async function readAgentPsdTemplates() { } export async function tryDeleteAgentPsdTemplateAssets(template: AgentPsdTemplate) { + await deleteAgentPsdLocalCache({ + templateId: template.id, + fileName: template.fileName, + }).catch(() => undefined); + const filePaths = Array.from( new Set( [ diff --git a/lib/agents/psd-template-style-resolver.ts b/lib/agents/psd-template-style-resolver.ts index c70fc49b2..1ae97ae1d 100644 --- a/lib/agents/psd-template-style-resolver.ts +++ b/lib/agents/psd-template-style-resolver.ts @@ -6,7 +6,8 @@ import { type AgentPsdTemplate, type AgentPsdTemplateTextLayerSlot, } from "@/lib/agents/psd-template-config"; -import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser"; +import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; +import { loadPsdTemplateSource } from "@/lib/agents/psd-template-parser"; function getEstimatedFontSize(height: number) { return Math.max(16, Math.min(height, Math.round(height * 0.78))); @@ -65,7 +66,13 @@ export async function resolveAgentPsdTemplateTextStyles(template: AgentPsdTempla return template; } - const source = await loadPsdTemplateSourceFromUrl(template.fileUrl); + const source = await loadPsdTemplateSource({ + fileUrl: template.fileUrl, + filePath: resolveAgentPsdLocalCachePath({ + templateId: template.id, + fileName: template.fileName, + }), + }); const sourceLayerMap = new Map(source.layers.map((entry) => [entry.layer.layerId, entry])); const pixelAnalysisCache = new Map< string,