fix: stabilize PSD render asset downloads
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:45:52 +08:00
parent 649825072b
commit c9c78f20d4

View File

@ -84,6 +84,7 @@ import {
} from "@/lib/agents/psd-text-layout";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error";
function buildTemplateListRequestUrl(params?: {
@ -242,6 +243,7 @@ const PREVIEW_STAGE_TARGET_HEIGHT = 760;
const MIN_IMAGE_SCALE = 0.4;
const MAX_IMAGE_SCALE = 3;
const UNASSIGNED_BRAND_LABEL = "未分类";
const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i;
function buildTemplatePreviewThumbUrl(
previewUrl: string,
@ -265,6 +267,76 @@ function buildTemplatePreviewThumbUrl(
return `/api/image-proxy?${searchParams.toString()}`;
}
function isLikelyStableRenderImageUrl(url: string) {
const normalized = String(url || "").trim();
if (!normalized) {
return true;
}
if (
normalized.startsWith("data:") ||
normalized.startsWith("blob:") ||
normalized.startsWith("/api/image-proxy")
) {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
const parsed = new URL(normalized, window.location.origin);
if (parsed.origin === window.location.origin) {
return true;
}
const hostname = parsed.hostname.toLowerCase();
return (
hostname.includes("aliyuncs.com") ||
hostname.includes("oss-cn-") ||
hostname.includes("cloudfront.net") ||
hostname.includes("r2.dev")
);
} catch {
return false;
}
}
function inferImageExtension(url: string, contentType: string) {
const normalizedContentType = String(contentType || "").toLowerCase();
if (normalizedContentType.includes("png")) {
return "png";
}
if (normalizedContentType.includes("webp")) {
return "webp";
}
if (
normalizedContentType.includes("jpeg") ||
normalizedContentType.includes("jpg")
) {
return "jpg";
}
if (normalizedContentType.includes("gif")) {
return "gif";
}
if (normalizedContentType.includes("avif")) {
return "avif";
}
try {
const pathname = new URL(url, "https://placeholder.invalid").pathname;
const matched = pathname.match(IMAGE_EXTENSION_REGEX);
if (matched?.[1]) {
return matched[1].toLowerCase();
}
} catch {
// Ignore malformed URLs and fall back to png.
}
return "png";
}
function normalizeManagedBrandName(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized || normalized === "未分配品牌") {
@ -1256,6 +1328,7 @@ export function AgentPsdTemplateEditorDialog({
const imagePlacementsRef = useRef(imagePlacements);
const textAssignmentsRef = useRef(textAssignments);
const textColorAssignmentsRef = useRef(textColorAssignments);
const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({});
const selectedLayerIdRef = useRef(selectedLayerId);
imageAssignmentsRef.current = imageAssignments;
@ -1264,6 +1337,12 @@ export function AgentPsdTemplateEditorDialog({
textColorAssignmentsRef.current = textColorAssignments;
selectedLayerIdRef.current = selectedLayerId;
useEffect(() => {
if (!open) {
archivedRenderImageUrlCacheRef.current = {};
}
}, [open]);
const selectedTemplateSummary = useMemo(
() =>
templates.find((template) => template.id === selectedTemplateId) || null,
@ -2179,6 +2258,101 @@ export function AgentPsdTemplateEditorDialog({
}));
}
async function archiveRenderImageUrl(
rawUrl: string,
label: string
): Promise<string> {
const normalizedUrl = String(rawUrl || "").trim();
if (!normalizedUrl || isLikelyStableRenderImageUrl(normalizedUrl)) {
return normalizedUrl;
}
const cachedUrl = archivedRenderImageUrlCacheRef.current[normalizedUrl];
if (cachedUrl) {
return cachedUrl;
}
const fetchCandidates = Array.from(
new Set([normalizedUrl, buildImageProxyUrl(normalizedUrl)].filter(Boolean))
);
let lastError: unknown = null;
for (const candidateUrl of fetchCandidates) {
try {
const response = await fetch(candidateUrl, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`${label}读取失败(${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error(`${label}读取结果为空`);
}
const extension = inferImageExtension(
normalizedUrl,
blob.type || response.headers.get("content-type") || ""
);
const formData = new FormData();
formData.set(
"file",
new File([blob], `psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`, {
type: blob.type || "image/png",
})
);
const uploadResponse = await fetchUploadWithRetry(formData);
const uploadPayload = await parseActionJsonResponse<{ url?: string }>(
uploadResponse,
`${label}转存失败`,
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
);
const uploadedUrl = String(uploadPayload?.url || "").trim();
if (!uploadedUrl) {
throw new Error(`${label}转存失败`);
}
archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl;
return uploadedUrl;
} catch (error) {
lastError = error;
}
}
throw new Error(
getUserFacingRequestErrorMessage(
lastError,
`${label}无法转存到本站存储,请重新上传图片后再试`
)
);
}
async function normalizeRenderImageInputs() {
const nextSourceImageUrl = await archiveRenderImageUrl(
sourceImageUrl || "",
"PSD 套版主图"
);
const nextImageAssignments = { ...effectiveImageAssignments };
const layerNameById = new Map(
editableImageLayers.map((layer) => [layer.id, layer.layerName])
);
for (const [layerId, layerUrl] of Object.entries(nextImageAssignments)) {
nextImageAssignments[layerId] = await archiveRenderImageUrl(
layerUrl,
`PSD 图层素材「${layerNameById.get(layerId) || layerId}」`
);
}
return {
sourceImageUrl: nextSourceImageUrl,
imageAssignments: nextImageAssignments,
};
}
function handleImageDragStart(
layer: AgentPsdTemplateImageLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
@ -2251,15 +2425,31 @@ export function AgentPsdTemplateEditorDialog({
try {
setIsRendering(true);
setRenderedResultUrl("");
const normalizedRenderInputs = await normalizeRenderImageInputs();
if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) {
const defaultSourceLayer = editableImageLayers.find(
(layer) => layer.isDefaultResultSource
);
if (defaultSourceLayer) {
setImageAssignments((current) => ({
...current,
[defaultSourceLayer.id]: normalizedRenderInputs.sourceImageUrl,
}));
}
}
setImageAssignments((current) => ({
...current,
...normalizedRenderInputs.imageAssignments,
}));
const payload = (await requestLongTaskResult({
endpoint: "/api/psd-templates/render",
timeoutMs: 8 * 60 * 1000,
maxPollFailures: 8,
payload: {
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
sourceImageUrl: normalizedRenderInputs.sourceImageUrl,
hiddenLayerIds,
imageAssignments: effectiveImageAssignments,
imageAssignments: normalizedRenderInputs.imageAssignments,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
textColorAssignments: effectiveTextColorAssignments,