减轻风格化入口加载并防止新建项目超时重复创建
风格化入口并发下载完整配置时,新建项目的响应可能超过前端超时限制,重复点击会产生多个空项目。现在未选项目时只加载轻量配置,进入项目后再加载完整风格配置;配置未变化时按更新时间返回 304,复用已加载数据。配置下载超时覆盖响应正文读取,完整配置保留 30 秒读取时间。 新建请求使用稳定的项目编号,数据库按主键去重;丢失响应后按编号核对结果,尚未确认时保留编号供重试和刷新恢复。已有项目的内容与账号归属检查保持有效。 验证:8 项专项测试通过,辅助测试 633 项通过、1 项原有跳过,TypeScript、改动文件 lint、日志脱敏、凭据、环境、元数据、测试和制品检查通过。全量 lint 仍有 1005 项历史诊断;发布构建和线上验收另行核对。
This commit is contained in:
parent
00cef71be7
commit
70f954382a
@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
@ -56,14 +57,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
|
||||
if (requestedId && !requestedId.success) {
|
||||
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
|
||||
}
|
||||
const id = requestedId?.data;
|
||||
const title = String(body.title || "").trim() || "未命名生图项目";
|
||||
const description = String(body.description || "").trim() || null;
|
||||
const thumbnail = String(body.thumbnail || "").trim() || null;
|
||||
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
||||
|
||||
const [project] = await db
|
||||
const [insertedProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
id,
|
||||
userId: session.user.id,
|
||||
title,
|
||||
description,
|
||||
@ -78,8 +85,22 @@ export async function POST(request: NextRequest) {
|
||||
agentProjectState: projectState,
|
||||
} as any,
|
||||
})
|
||||
.onConflictDoNothing({ target: canvasProject.id })
|
||||
.returning();
|
||||
|
||||
const project = insertedProject || (id ? (await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
eq(canvasProject.workspaceType, "agent")
|
||||
))
|
||||
.limit(1))[0] : undefined);
|
||||
if (!project) {
|
||||
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
|
||||
} catch (error) {
|
||||
console.error("[AgentProjects API] 创建项目失败:", error);
|
||||
|
||||
@ -153,7 +153,7 @@ import {
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||
import { withTimeout } from "@/lib/public-api/performance";
|
||||
import type { AgentCategoryTreeConfig } from "@/types/agent";
|
||||
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent";
|
||||
|
||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
||||
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
||||
@ -553,6 +553,7 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
|
||||
type PublicSiteSettingsScope =
|
||||
| "agent-brand-model"
|
||||
| "agent-canvas"
|
||||
| "agent-entry"
|
||||
| "agent-presets"
|
||||
| "auth"
|
||||
| "branding"
|
||||
@ -564,6 +565,7 @@ type PublicSiteSettingsScope =
|
||||
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||
"agent-brand-model",
|
||||
"agent-canvas",
|
||||
"agent-entry",
|
||||
"agent-presets",
|
||||
"auth",
|
||||
"branding",
|
||||
@ -607,22 +609,60 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
||||
};
|
||||
|
||||
const AGENT_CANVAS_SETTING_KEYS = [
|
||||
REALTIME_CANVAS_CONFIG_KEY,
|
||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
||||
LAYER_SPLIT_CONFIG_KEY,
|
||||
ANGLE_EDIT_CONFIG_KEY,
|
||||
CANVAS_AGENT_MODE_ENABLED_KEY,
|
||||
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
||||
VIDEO_CANVAS_ENABLED_KEY,
|
||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||
AI_WASH_CONFIG_KEY,
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
||||
];
|
||||
|
||||
async function getAgentCanvasSettingsEtag(scope: string) {
|
||||
const [settings, rows, providers] = await Promise.all([
|
||||
db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt })
|
||||
.from(siteSettings).limit(1),
|
||||
db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt })
|
||||
.from(systemSettings)
|
||||
.where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)),
|
||||
db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled })
|
||||
.from(aiProvider)
|
||||
.where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])),
|
||||
]);
|
||||
const versions = [
|
||||
...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`),
|
||||
...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`),
|
||||
...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`),
|
||||
].sort();
|
||||
return `W/"${scope}/${versions.join(",")}"`;
|
||||
}
|
||||
|
||||
function createScopedSiteSettingsResponse(
|
||||
scope: PublicSiteSettingsScope,
|
||||
data: Record<string, unknown>,
|
||||
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
||||
cacheKey: string = scope,
|
||||
options: { fallback?: boolean; startedAt?: number } = {}
|
||||
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
|
||||
) {
|
||||
if (cacheStatus === "MISS") {
|
||||
setScopedSiteSettingsCache(
|
||||
cacheKey,
|
||||
data,
|
||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
|
||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
||||
options.etag
|
||||
);
|
||||
}
|
||||
|
||||
const response = NextResponse.json(data);
|
||||
if (options.etag) response.headers.set("ETag", options.etag);
|
||||
// Lightweight public settings change rarely; keep them browser-local briefly
|
||||
// so page opens do not repeatedly wait on the large full settings endpoint.
|
||||
response.headers.set(
|
||||
@ -677,6 +717,7 @@ async function getScopedPublicSiteSettings(
|
||||
options: {
|
||||
brandName?: string;
|
||||
cacheKey?: string;
|
||||
etag?: string;
|
||||
} = {}
|
||||
) {
|
||||
if (scope === "auth") {
|
||||
@ -738,23 +779,8 @@ async function getScopedPublicSiteSettings(
|
||||
});
|
||||
}
|
||||
|
||||
if (scope === "agent-canvas") {
|
||||
const { settings, valueByKey } = await readScopedSiteSettings([
|
||||
REALTIME_CANVAS_CONFIG_KEY,
|
||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
||||
LAYER_SPLIT_CONFIG_KEY,
|
||||
ANGLE_EDIT_CONFIG_KEY,
|
||||
CANVAS_AGENT_MODE_ENABLED_KEY,
|
||||
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
|
||||
VIDEO_CANVAS_ENABLED_KEY,
|
||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||
AI_WASH_CONFIG_KEY,
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
||||
]);
|
||||
if (scope === "agent-canvas" || scope === "agent-entry") {
|
||||
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS);
|
||||
const canvasFeatureAvailability = await readCanvasFeatureAvailability(
|
||||
valueByKey
|
||||
);
|
||||
@ -762,6 +788,8 @@ async function getScopedPublicSiteSettings(
|
||||
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
||||
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
||||
);
|
||||
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
|
||||
Partial<AgentWorkflowUiConfig> | undefined;
|
||||
return createScopedSiteSettingsResponse(scope, {
|
||||
...createBrandingSettings(typedSettings),
|
||||
agentEnabled: typedSettings?.agentEnabled ?? true,
|
||||
@ -797,12 +825,18 @@ async function getScopedPublicSiteSettings(
|
||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
|
||||
scope === "agent-entry"
|
||||
? {
|
||||
steps: [],
|
||||
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
|
||||
businessModeSettings: rawUiConfig?.businessModeSettings,
|
||||
}
|
||||
: rawUiConfig
|
||||
),
|
||||
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
||||
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
||||
),
|
||||
});
|
||||
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
||||
}
|
||||
|
||||
if (scope === "branding") {
|
||||
@ -1056,13 +1090,23 @@ export async function GET(request: NextRequest) {
|
||||
const cacheKey = brandName.trim()
|
||||
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
||||
: scopedScope;
|
||||
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
|
||||
? await getAgentCanvasSettingsEtag(scopedScope)
|
||||
: undefined;
|
||||
if (etag && request.headers.get("If-None-Match") === etag) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
|
||||
});
|
||||
}
|
||||
const cached = getScopedSiteSettingsCache(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
|
||||
return createScopedSiteSettingsResponse(
|
||||
scopedScope,
|
||||
cached.data,
|
||||
"HIT",
|
||||
cacheKey
|
||||
cacheKey,
|
||||
{ etag }
|
||||
);
|
||||
}
|
||||
if (cached && scopedScope !== "home") {
|
||||
@ -1073,6 +1117,7 @@ export async function GET(request: NextRequest) {
|
||||
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
||||
brandName,
|
||||
cacheKey,
|
||||
etag,
|
||||
});
|
||||
return scopedScope === "home"
|
||||
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
||||
|
||||
@ -228,6 +228,7 @@ import {
|
||||
type WorkflowSceneRecord,
|
||||
} from "@/lib/video-workflow/runtime";
|
||||
import { agentProjectService } from "@/services/agent-projects";
|
||||
import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
|
||||
import {
|
||||
canvasService,
|
||||
imageGenerationService,
|
||||
@ -265,30 +266,8 @@ import type {
|
||||
} from "@/types/canvas";
|
||||
|
||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
||||
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
||||
|
||||
const fetchCanvasSiteSettings = async (url: string) => {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, CANVAS_SITE_SETTINGS_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(`${url}${separator}t=${Date.now()}`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch canvas site settings");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
function CanvasLazyPanelLoader({
|
||||
label,
|
||||
className,
|
||||
@ -2185,7 +2164,9 @@ export default function CanvasEditorLegacyPage({
|
||||
[agentProjectId, id]
|
||||
);
|
||||
const canvasSiteSettingsKey = requestedAgentMode
|
||||
? "/api/site-settings?scope=agent-canvas"
|
||||
? hasActiveAgentProject
|
||||
? "/api/site-settings?scope=agent-canvas"
|
||||
: "/api/site-settings?scope=agent-entry"
|
||||
: "/api/site-settings?scope=canvas";
|
||||
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
|
||||
useState(false);
|
||||
@ -2221,6 +2202,7 @@ export default function CanvasEditorLegacyPage({
|
||||
? 0
|
||||
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
|
||||
errorRetryCount: 1,
|
||||
dedupingInterval: 30_000,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
});
|
||||
@ -2337,11 +2319,18 @@ export default function CanvasEditorLegacyPage({
|
||||
});
|
||||
const sanitizedWorkflowSelections = useMemo(
|
||||
() =>
|
||||
sanitizeAgentWorkflowSelections({
|
||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||
selections: workflowSelections,
|
||||
}),
|
||||
[resolvedWorkflowUiConfig, workflowSelections]
|
||||
requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
|
||||
? workflowSelections
|
||||
: sanitizeAgentWorkflowSelections({
|
||||
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
|
||||
selections: workflowSelections,
|
||||
}),
|
||||
[
|
||||
requestedAgentMode,
|
||||
siteSettingsData?.agentWorkflowUiConfig,
|
||||
resolvedWorkflowUiConfig,
|
||||
workflowSelections,
|
||||
]
|
||||
);
|
||||
const deferredSanitizedWorkflowSelections = useDeferredValue(
|
||||
sanitizedWorkflowSelections
|
||||
@ -4699,7 +4688,7 @@ export default function CanvasEditorLegacyPage({
|
||||
title: nextTitle,
|
||||
thumbnail: agentProjectThumbnail,
|
||||
projectState: buildPersistedAgentProjectState(),
|
||||
});
|
||||
}, { creationKey: `save-as:${id}:${agentProjectId}:${nextTitle}` });
|
||||
if (!result.success || !result.data) {
|
||||
if (isUnauthorizedApiError(result.error)) {
|
||||
setIsAuthenticated(false);
|
||||
@ -4724,6 +4713,7 @@ export default function CanvasEditorLegacyPage({
|
||||
agentProjectThumbnail,
|
||||
agentProjectTitle,
|
||||
buildPersistedAgentProjectState,
|
||||
id,
|
||||
openLoginDialog,
|
||||
persistAgentProject,
|
||||
updateCanvasSearchParams,
|
||||
@ -4788,7 +4778,7 @@ export default function CanvasEditorLegacyPage({
|
||||
const result = await agentProjectService.createProject({
|
||||
title: buildDefaultAgentProjectTitle(),
|
||||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||||
});
|
||||
}, { creationKey: `blank:${id}:${agentProjectId}` });
|
||||
if (!result.success || !result.data) {
|
||||
if (isUnauthorizedApiError(result.error)) {
|
||||
setIsAuthenticated(false);
|
||||
@ -4811,6 +4801,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}, [
|
||||
agentProjectId,
|
||||
isAgentProjectSaving,
|
||||
id,
|
||||
openLoginDialog,
|
||||
persistAgentProject,
|
||||
updateCanvasSearchParams,
|
||||
@ -7060,7 +7051,9 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const result = existingProject
|
||||
? await agentProjectService.updateProject(existingProject.id, payload)
|
||||
: await agentProjectService.createProject(payload);
|
||||
: await agentProjectService.createProject(payload, {
|
||||
creationKey: `workflow:${id}:${nodeId}`,
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
if (isUnauthorizedApiError(result.error)) {
|
||||
|
||||
24
lib/client/canvas-site-settings.ts
Normal file
24
lib/client/canvas-site-settings.ts
Normal file
@ -0,0 +1,24 @@
|
||||
const settingsCache = new Map<string, { etag: string; data: unknown }>();
|
||||
|
||||
export async function fetchCanvasSiteSettings(url: string) {
|
||||
const cached = settingsCache.get(url);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
url === "/api/site-settings?scope=agent-canvas" ? 30_000 : 10_000
|
||||
);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: cached ? { "If-None-Match": cached.etag } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 304 && cached) return cached.data;
|
||||
if (!response.ok) throw new Error("Failed to fetch canvas site settings");
|
||||
const data = await response.json();
|
||||
const etag = response.headers.get("ETag");
|
||||
if (etag) settingsCache.set(url, { etag, data });
|
||||
return data;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ const PUBLIC_SITE_SETTINGS_CACHE_KEY = Symbol.for(
|
||||
type PublicSiteSettingsCacheEntry = {
|
||||
data: Record<string, unknown>;
|
||||
expiresAt: number;
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
function getPublicSiteSettingsCacheStore() {
|
||||
@ -34,10 +35,12 @@ export function setScopedSiteSettingsCache(
|
||||
cacheKey: string,
|
||||
data: Record<string, unknown>,
|
||||
ttlMs: number = PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
||||
etag?: string,
|
||||
) {
|
||||
getPublicSiteSettingsCacheStore().set(cacheKey, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
etag,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/agent-project-recovery\.test\.ts/,
|
||||
/routes\/admin-media-loading\.test\.ts/,
|
||||
/routes\/video-canvas-production\.test\.ts/,
|
||||
/routes\/video-canvas-media-import\.test\.ts/,
|
||||
|
||||
@ -7,6 +7,7 @@ interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
const AGENT_PROJECT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
@ -26,12 +27,38 @@ async function fetchAgentProjectApi<T>(
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await response.json();
|
||||
const result = await response.json();
|
||||
if (!response.ok) result.status = response.status;
|
||||
return result;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
type CreateProjectPayload = {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
thumbnail?: string | null;
|
||||
projectState?: AgentGenerationProjectPersistedState;
|
||||
};
|
||||
|
||||
const pendingCreations = new Map<string, {
|
||||
id: string;
|
||||
promise?: Promise<ApiResponse<AgentGenerationProject>>;
|
||||
}>();
|
||||
|
||||
function pendingCreationStorage(key: string, value?: string | null) {
|
||||
try {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
if (value === undefined) return sessionStorage.getItem(key);
|
||||
if (value === null) sessionStorage.removeItem(key);
|
||||
else sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Keep the in-memory request identity when browser storage is unavailable.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAgentProjectRequestErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return `${fallback}:请求超时`;
|
||||
@ -71,27 +98,52 @@ export const agentProjectService = {
|
||||
}
|
||||
},
|
||||
|
||||
async createProject(payload: {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
thumbnail?: string | null;
|
||||
projectState?: AgentGenerationProjectPersistedState;
|
||||
}): Promise<ApiResponse<AgentGenerationProject>> {
|
||||
try {
|
||||
return await fetchAgentProjectApi<AgentGenerationProject>(
|
||||
"/api/agent-projects",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
async createProject(
|
||||
payload: CreateProjectPayload,
|
||||
options: { creationKey?: string } = {}
|
||||
): Promise<ApiResponse<AgentGenerationProject>> {
|
||||
const key = `agent-project:create:${options.creationKey || JSON.stringify(payload)}`;
|
||||
let pending = pendingCreations.get(key);
|
||||
if (!pending) {
|
||||
pending = { id: pendingCreationStorage(key) || crypto.randomUUID() };
|
||||
pendingCreations.set(key, pending);
|
||||
pendingCreationStorage(key, pending.id);
|
||||
}
|
||||
if (pending.promise) return pending.promise;
|
||||
const current = pending;
|
||||
const clearPending = () => {
|
||||
pendingCreations.delete(key);
|
||||
pendingCreationStorage(key, null);
|
||||
};
|
||||
current.promise = (async () => {
|
||||
try {
|
||||
const result = await fetchAgentProjectApi<AgentGenerationProject>(
|
||||
"/api/agent-projects",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, id: current.id }),
|
||||
}
|
||||
);
|
||||
if (result.success || (result.status && result.status < 500)) {
|
||||
clearPending();
|
||||
return result;
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// A lost response does not establish whether the project was created.
|
||||
}
|
||||
const recovered = await agentProjectService.getProject(current.id);
|
||||
if (recovered.success && recovered.data) {
|
||||
clearPending();
|
||||
return recovered;
|
||||
}
|
||||
if (recovered.status === 401) return recovered;
|
||||
return {
|
||||
success: false,
|
||||
error: getAgentProjectRequestErrorMessage(error, "创建生图项目失败"),
|
||||
error: "创建结果尚未确认,请重试核对或打开历史项目;重试不会重复创建。",
|
||||
};
|
||||
}
|
||||
})().finally(() => { current.promise = undefined; });
|
||||
return current.promise;
|
||||
},
|
||||
|
||||
async updateProject(
|
||||
|
||||
229
tests/routes/agent-project-recovery.test.ts
Normal file
229
tests/routes/agent-project-recovery.test.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { randomUUID, webcrypto } from "node:crypto";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const id = randomUUID();
|
||||
const owner = randomUUID();
|
||||
|
||||
function loadRuntime(mocks: Record<string, any> = {}, globals: Record<string, any> = {}) {
|
||||
const modules = new Map<string, any>();
|
||||
const context = vm.createContext({
|
||||
console, Buffer, URL, Response, Request, Headers, DOMException,
|
||||
AbortController, AbortSignal, crypto: webcrypto, setTimeout, clearTimeout,
|
||||
process: { env: {} },
|
||||
fetch: () => { throw new Error("Unexpected network request"); },
|
||||
...globals,
|
||||
});
|
||||
const load = (relative: string): any => {
|
||||
let filename = path.resolve(root, relative);
|
||||
if (!existsSync(filename)) filename = path.join(filename.replace(/\.ts$/, ""), "index.ts");
|
||||
if (modules.has(filename)) return modules.get(filename).exports;
|
||||
const module = { exports: {} };
|
||||
modules.set(filename, module);
|
||||
const nativeRequire = createRequire(filename);
|
||||
const requireMock = (name: string) => {
|
||||
if (name === "server-only") return {};
|
||||
if (Object.hasOwn(mocks, name)) return mocks[name];
|
||||
if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`);
|
||||
if (name.startsWith(".")) return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`)));
|
||||
return nativeRequire(name);
|
||||
};
|
||||
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||
}).outputText;
|
||||
context.module = module;
|
||||
context.exports = module.exports;
|
||||
context.require = requireMock;
|
||||
const fn = new vm.Script(`(function(module,exports,require){${source}\n})`, { filename }).runInContext(context);
|
||||
fn(module, module.exports, requireMock);
|
||||
return module.exports;
|
||||
};
|
||||
return load;
|
||||
}
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return { getItem: (key: string) => values.get(key) || null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) };
|
||||
}
|
||||
|
||||
function projectRoute() {
|
||||
const projects = new Map<string, any>();
|
||||
let userId: string | null = owner;
|
||||
const db = {
|
||||
insert: () => ({ values: (value: any) => ({ onConflictDoNothing: () => ({ returning: async () => {
|
||||
const row = { ...value, id: value.id || randomUUID(), createdAt: new Date(), updatedAt: new Date() };
|
||||
if (projects.has(row.id)) return [];
|
||||
projects.set(row.id, row);
|
||||
return [row];
|
||||
} }) }) }),
|
||||
select: () => ({ from: () => ({ where: (conditions: any[]) => ({ limit: async () => [...projects.values()].filter((row) => conditions.every(([key, value]) => row[key] === value)) }) }) }),
|
||||
};
|
||||
const load = loadRuntime({
|
||||
"@/lib/db": { db },
|
||||
"@/lib/db/schema": { canvasProject: { id: "id", userId: "userId", workspaceType: "workspaceType" } },
|
||||
"@/app/(auth)/auth": { auth: async () => userId ? { user: { id: userId } } : null },
|
||||
"drizzle-orm": { eq: (key: string, value: unknown) => [key, value], and: (...parts: unknown[]) => parts, desc: (value: unknown) => value },
|
||||
});
|
||||
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
|
||||
}
|
||||
|
||||
test("duplicate POSTs return one project without overwriting saved state", async () => {
|
||||
const { route, projects } = projectRoute();
|
||||
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
|
||||
const responses = await Promise.all([post("原项目"), post("重试标题")]);
|
||||
expect(projects.size).toBe(1);
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).data).toMatchObject({ id, title: "原项目", userId: owner });
|
||||
}
|
||||
projects.get(id).title = "已编辑项目";
|
||||
expect((await (await post("迟到的重试")).json()).data.title).toBe("已编辑项目");
|
||||
});
|
||||
|
||||
test("creation retains account ownership and accepts existing clients", async () => {
|
||||
const { route, projects, setUser } = projectRoute();
|
||||
const post = (body: unknown) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify(body) }));
|
||||
expect((await post({ id })).status).toBe(200);
|
||||
setUser(randomUUID());
|
||||
const conflict = await post({ id });
|
||||
expect(conflict.status).toBe(409);
|
||||
expect((await conflict.json()).data).toBeUndefined();
|
||||
expect(projects.size).toBe(1);
|
||||
expect((await post({ title: "旧客户端" })).status).toBe(200);
|
||||
expect((await post({ id: "invalid" })).status).toBe(400);
|
||||
setUser(null);
|
||||
expect((await post({ id: randomUUID() })).status).toBe(401);
|
||||
});
|
||||
|
||||
test("a lost create response recovers the inserted project", async () => {
|
||||
let project: any;
|
||||
let posts = 0;
|
||||
const fetchMock = async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
posts++;
|
||||
project = JSON.parse(String(init.body));
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
return Response.json({ success: true, data: project });
|
||||
};
|
||||
const { agentProjectService } = loadRuntime({}, { fetch: fetchMock, sessionStorage: memoryStorage() })("services/agent-projects/index.ts");
|
||||
const result = await agentProjectService.createProject({ title: "新项目" }, { creationKey: "blank:test" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.id).toBe(project.id);
|
||||
expect(posts).toBe(1);
|
||||
});
|
||||
|
||||
test("unconfirmed creation survives reload and retries with the same project ID", async () => {
|
||||
const storage = memoryStorage();
|
||||
const ids: string[] = [];
|
||||
let offline = true;
|
||||
const fetchMock = async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
ids.push(body.id);
|
||||
if (!offline) return Response.json({ success: true, data: body });
|
||||
throw new TypeError("Connection closed");
|
||||
}
|
||||
return Response.json({ success: false }, { status: 404 });
|
||||
};
|
||||
const service = () => loadRuntime({}, { fetch: fetchMock, sessionStorage: storage })("services/agent-projects/index.ts").agentProjectService;
|
||||
const first = await service().createProject({ title: "09:18" }, { creationKey: "blank:test" });
|
||||
expect(first.error).toContain("尚未确认");
|
||||
offline = false;
|
||||
const reloaded = service();
|
||||
expect((await reloaded.createProject({ title: "09:21" }, { creationKey: "blank:test" })).success).toBe(true);
|
||||
expect(ids[1]).toBe(ids[0]);
|
||||
await reloaded.createProject({ title: "新的独立项目" }, { creationKey: "blank:test" });
|
||||
expect(ids[2]).not.toBe(ids[0]);
|
||||
});
|
||||
|
||||
test("simultaneous clicks share one create request", async () => {
|
||||
let posts = 0;
|
||||
let finish!: () => void;
|
||||
const wait = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const service = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => {
|
||||
posts++;
|
||||
await wait;
|
||||
return Response.json({ success: true, data: JSON.parse(String(init.body)) });
|
||||
} })("services/agent-projects/index.ts").agentProjectService;
|
||||
const first = service.createProject({}, { creationKey: "blank:double" });
|
||||
const second = service.createProject({}, { creationKey: "blank:double" });
|
||||
finish();
|
||||
const results = await Promise.all([first, second]);
|
||||
expect(posts).toBe(1);
|
||||
expect(results[0].data.id).toBe(results[1].data.id);
|
||||
});
|
||||
|
||||
test("conditional settings requests reuse the full config after 304", async () => {
|
||||
const headers: Headers[] = [];
|
||||
const config = { steps: [{ key: "style", promptFragment: "保留提示词" }] };
|
||||
const { fetchCanvasSiteSettings } = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => {
|
||||
headers.push(new Headers(init.headers));
|
||||
return headers.length === 1 ? Response.json(config, { headers: { ETag: 'W/"revision-1"' } }) : new Response(null, { status: 304 });
|
||||
} })("lib/client/canvas-site-settings.ts");
|
||||
const first = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas");
|
||||
const second = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas");
|
||||
expect(second).toBe(first);
|
||||
expect(second).toEqual(config);
|
||||
expect(headers[1].get("If-None-Match")).toBe('W/"revision-1"');
|
||||
});
|
||||
|
||||
test("settings timeout also aborts a stalled response body", async () => {
|
||||
const { fetchCanvasSiteSettings } = loadRuntime({}, {
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 5),
|
||||
fetch: async (_url: string, init: RequestInit) => ({ ok: true, status: 200, json: () => new Promise((_, reject) => {
|
||||
init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
}) }),
|
||||
})("lib/client/canvas-site-settings.ts");
|
||||
await expect(fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas")).rejects.toHaveProperty("name", "AbortError");
|
||||
});
|
||||
|
||||
test("entry config stays small and unchanged config returns 304 before loading values", async () => {
|
||||
let revision = new Date("2026-11-04T00:00:00Z");
|
||||
let fullReads = 0;
|
||||
const tables = { siteSettings: { id: "siteId", updatedAt: "siteUpdatedAt" }, systemSettings: { key: "key", updatedAt: "updatedAt" }, aiProvider: { id: "providerId", updatedAt: "providerUpdatedAt", isEnabled: "enabled", type: "type" } };
|
||||
const ui = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [{ key: "test", buttonName: "风格一", promptFragment: "生成提示词".repeat(3000), enabled: true, sortOrder: 0 }] }] };
|
||||
const db = { select: (columns: unknown) => ({ from: (table: unknown) => {
|
||||
const result = () => {
|
||||
if (table === tables.aiProvider) return [];
|
||||
if (table === tables.siteSettings) return [{ id: "site", siteName: "由壹点AI", updatedAt: revision }];
|
||||
if (!columns) fullReads++;
|
||||
return [{ key: "agent_workflow_ui_config", value: ui, updatedAt: revision }];
|
||||
};
|
||||
const query: any = Object.assign(Promise.resolve(result()), {
|
||||
where: () => query,
|
||||
limit: () => query,
|
||||
});
|
||||
return query;
|
||||
} }) };
|
||||
const route = loadRuntime({
|
||||
"@/lib/db": { db }, "@/lib/db/schema": tables,
|
||||
"@/app/(auth)/auth": { auth: async () => null },
|
||||
})("app/api/site-settings/route.ts");
|
||||
const request = (scope: string, etag?: string) => {
|
||||
const req = new Request(`http://localhost/api/site-settings?scope=${scope}`, { headers: etag ? { "If-None-Match": etag } : {} });
|
||||
return Object.assign(req, { nextUrl: new URL(req.url) });
|
||||
};
|
||||
const entry = await route.GET(request("agent-entry"));
|
||||
expect(entry.status).toBe(200);
|
||||
const entryData = await entry.json();
|
||||
expect(entryData.agentWorkflowUiConfig.steps).toEqual([]);
|
||||
expect(JSON.stringify(entryData).length).toBeLessThan(20_000);
|
||||
const full = await route.GET(request("agent-canvas"));
|
||||
expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000));
|
||||
const etag = full.headers.get("ETag");
|
||||
const reads = fullReads;
|
||||
const unchanged = await route.GET(request("agent-canvas", etag));
|
||||
expect(unchanged.status).toBe(304);
|
||||
expect(await unchanged.text()).toBe("");
|
||||
expect(fullReads).toBe(reads);
|
||||
revision = new Date("2026-11-04T00:01:00Z");
|
||||
const changed = await route.GET(request("agent-canvas", etag));
|
||||
expect(changed.status).toBe(200);
|
||||
expect(changed.headers.get("ETag")).not.toBe(etag);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user