减轻风格化入口加载并防止新建项目超时重复创建

风格化入口并发下载完整配置时,新建项目的响应可能超过前端超时限制,重复点击会产生多个空项目。现在未选项目时只加载轻量配置,进入项目后再加载完整风格配置;配置未变化时按更新时间返回 304,复用已加载数据。配置下载超时覆盖响应正文读取,完整配置保留 30 秒读取时间。

新建请求使用稳定的项目编号,数据库按主键去重;丢失响应后按编号核对结果,尚未确认时保留编号供重试和刷新恢复。已有项目的内容与账号归属检查保持有效。

验证:8 项专项测试通过,辅助测试 633 项通过、1 项原有跳过,TypeScript、改动文件 lint、日志脱敏、凭据、环境、元数据、测试和制品检查通过。全量 lint 仍有 1005 项历史诊断;发布构建和线上验收另行核对。
This commit is contained in:
WuYongJie 2026-11-04 09:46:45 +08:00
parent 00cef71be7
commit 70f954382a
8 changed files with 443 additions and 75 deletions

View File

@ -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);

View File

@ -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")

View File

@ -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)) {

View 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);
}
}

View File

@ -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,
});
}

View File

@ -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/,

View File

@ -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(

View 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);
});