完美像素刷新后的孤儿占位由归属标记收口
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Failing after 19s
Project CI / Native shell tests (pull_request) Failing after 4m53s

完美像素在 POST 前把 generating 占位落库后走同步 HTTP。刷新页面会丢弃服务端
handler,画布回填不再发生;新页面 hydrate 原样还原 generating,加载期没有任何
对账,占位永久转圈且 Esc 与关闭按钮都消不掉。

写入侧无解:validate_editor_pixel_art_snap_placeholder_exists 要求占位必须已
落库,否则 409,POST 前那句 flush 正是为此存在。改为读取侧收口。

给 dialog 增加 requiresLiveSession,加载时剥离置位且仍为 generating 的占位。
判据是结构性不变量而非时间阈值:这类占位只能由创建它的会话收口,活着的那份始终
在内存里不经过 hydrate,所以从服务端读回来的必然属于已死会话。队列型链路一律不
置位——去除背景恒返回 queueState、图片生成默认入队,它们的 job 在服务端继续跑,
误清会诱发重复提交。

剥离只用于项目首次加载的两个调用点,不下沉进 hydrate:会话内
applyQueuedEditorGenerationProject 也会重新 GET 并套用,那时占位对应的操作正在
进行。提示只指路不断言,因为服务端持久化非事务,前几步可能已成功。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 04:05:43 +00:00
parent 2493afe0ce
commit a2b19773d3
8 changed files with 211 additions and 3 deletions
@@ -5982,5 +5982,17 @@
- 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。
- 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。
- 文案边界:对账尾句只下指令、不断言素材库已刷新。`refreshAssetLibrary``canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时弹登录框、其余一律吞掉,返回 `Promise<void>` 不带成败信号,且它本身是可选 prop——三种情况下「已刷新」都是假话,会让用户对着旧列表判定「没有派生图,可以重试」,重新走回这条修复要避免的重复创建。刷新照旧调用(成功时用户白赚一份新列表),但文案在刷新失效时也必须成立。
- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口,需先给 dialog 增加「无 durable job 的 inline 链路」标记,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。
- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口(已由 2026-08-03 的 `requiresLiveSession` 条目解决)。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-03 完美像素刷新后的孤儿占位由归属标记收口
- 缺陷:完美像素在 POST 前 `flushProjectPersistence()``status: 'generating'` 的占位落库,随后走同步 HTTP。此时刷新页面,浏览器断连、handler future 被丢弃,服务端不会走完画布回填;新页面 hydrate 时 `status` 被原样还原(`isGenerationStatus``generating`),而加载期没有任何对账、轮询或重试 GET,占位就永久停在转圈状态。它还消不掉——Esc 被 `useImageCanvasKeyboardShortcuts``status === 'generating'` 挡,composer 关闭被 `closeCanvasGenerationComposer` 的同一判断挡。
- 归因:序列化设施是存量,但这个组合是本分支首次出现。同类路径逐条比对——去除背景的 `EditorBackgroundRemovalResult``queueState` 是非可选字段,恒走 durable job,worker 会在服务端替换占位;拆分图集根本不创建占位;图片生成等提交类链路在默认 `ExternalGenerationMode::Queue` 下同样入队,只有显式设 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 的部署才同步执行。完美像素的 handler 353 行全同步,`tokio::spawn` / `enqueue_` 各 0 次,`EditorPixelArtSnapResult``queueState`,是唯一「持久化 generating + 无 durable job」的链路。
- 方案取舍:不能从写入侧解决。`validate_editor_pixel_art_snap_placeholder_exists` 要求占位必须已落库,否则返回 `409`「完美像素画布占位不存在或尚未保存,请重试」——不持久化占位会让每一次完美像素都失败。POST 前那句 flush 正是为满足该门禁而存在。因此只能在读取侧收口,纯前端。
- 决策:给 dialog 增加 `requiresLiveSession` 标记,加载时由 `dropDeadInlineGenerationPlaceholders` 剥离置位且仍为 `generating` 的占位。判据是结构性不变量而非时间阈值:这类占位的收口只能由创建它的会话完成,而活着的那一份始终在内存里、永远不经过快照 hydrate,所以凡是从服务端快照读回来的必然属于已死会话。因此不需要时间戳,也不必猜阈值。队列型占位一律不置位——它们的 job 在服务端继续跑,误清会让用户以为操作没发生而重复提交。hydrate 只认布尔 `true`,缺字段的历史占位按队列型处理,不会被误清。
- 作用域:剥离只用在项目首次加载的两个调用点(会话缓存与权威快照都要,否则首屏会先闪一个永远转圈的占位)。**不能**下沉进 `hydrateCanvasGenerationDialog`——会话内 `applyQueuedEditorGenerationProject` 也会重新 GET 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。唯一置位点是完美像素占位的创建处。
- 提示文案:服务端持久化顺序 `OSS PUT → asset object → project resource → editor asset → 画布回填` 是非事务的,加载时还看到 `generating` 只说明最后一步没做完,前面几步可能已成功。所以不能断言「什么都没发生」,只提示「画布占位已清理,请确认素材库是否已生成派生图」,与同链路的对账文案同一口径。计数用累计值而非布尔——同一会话可能连着切换多个项目,布尔只提示一次。
- 已知残留:剥离是本地的,不主动回写。`applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 被消费掉,所以清理要等用户下一次布局变更才随防抖落库;在此之前重复打开会重复提示。刻意不强制回写:那会加剧多标签页问题——B 标签加载时会误判 A 标签正在跑的占位为孤儿,只在本地剥离时 A 的回填仍能成功,一旦立即回写就会让 A 撞上 `409` 占位不存在。多标签页同时编辑同一画布另有 CAS `expected_revision` 兜底,不由本次修复承担。
- 验证:`dropDeadInlineGenerationPlaceholders` 四条单测覆盖「剥离已死 inline 占位」「保留队列型占位(缺字段与显式 false 两种)」「保留已终态的 inline 占位与普通图层」「标记经 hydrate 与序列化往返不丢失」——最后一条钉住白名单式 hydrate 漏字段会让标记在一次「加载→保存」后消失。工作流测试新增 `live-session-dialogs` 探针,正向断言完美像素占位置位、反向断言去除背景占位不置位。`vitest src/components/image-editor` 893 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest';
import type {
EditorProjectLayerSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
CANVAS_WORLD_ORIGIN,
canvasDisplayScaleToViewportScale,
canvasDisplayViewportToViewport,
createLayerFromAsset,
DEFAULT_CANVAS_BACKGROUND_COLOR,
dropDeadInlineGenerationPlaceholders,
formatCanvasDisplayScalePercent,
hydrateCanvasGenerationDialog,
hydrateLayer,
@@ -993,4 +998,101 @@ describe('ImageCanvasEditorModel', () => {
horizontal: 160,
});
});
describe('dropDeadInlineGenerationPlaceholders', () => {
const buildProject = (
layers: EditorProjectLayerSnapshot[],
): EditorProjectSnapshot => ({
projectId: 'project-1',
title: '画布',
viewport: { x: 0, y: 0, scale: 1 },
layers,
resources: [],
updatedAt: '2026-08-03T00:00:00.000Z',
});
const buildDialogItem = (
id: string,
dialog: Record<string, unknown>,
): EditorProjectLayerSnapshot =>
({
itemType: 'generation-dialog',
layerId: `generation-dialog:${id}`,
resourceId: `generation-dialog:${id}`,
dialog: { id, mode: 'quick-edit', prompt: '完美像素', ...dialog },
}) as unknown as EditorProjectLayerSnapshot;
it('drops generating placeholders whose only owner was a dead session', () => {
const project = buildProject([
buildDialogItem('dead', {
status: 'generating',
requiresLiveSession: true,
}),
]);
const result = dropDeadInlineGenerationPlaceholders(project);
expect(result.droppedCount).toBe(1);
expect(result.project.layers).toEqual([]);
});
it('keeps generating placeholders backed by a durable job', () => {
// 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会
// 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。
const project = buildProject([
buildDialogItem('queued', { status: 'generating' }),
buildDialogItem('queued-explicit-false', {
status: 'generating',
requiresLiveSession: false,
}),
]);
const result = dropDeadInlineGenerationPlaceholders(project);
expect(result.droppedCount).toBe(0);
expect(result.project).toBe(project);
});
it('keeps settled inline placeholders and unrelated layout items', () => {
const settled = buildDialogItem('settled', {
status: 'failed',
requiresLiveSession: true,
});
const imageLayer = {
itemType: 'layer',
layerId: 'layer-1',
resourceId: 'resource-1',
} as unknown as EditorProjectLayerSnapshot;
const project = buildProject([settled, imageLayer]);
const result = dropDeadInlineGenerationPlaceholders(project);
expect(result.droppedCount).toBe(0);
expect(result.project.layers).toEqual([settled, imageLayer]);
});
it('round-trips the marker through hydration so reloads can still detect it', () => {
// 中文注释:hydrate 是白名单式的,漏掉这个字段会让标记在一次「加载→保存」后消失,
// 孤儿占位重新变得不可识别。
const hydrated = hydrateCanvasGenerationDialog({
id: 'dialog-1',
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
requiresLiveSession: true,
});
expect(hydrated?.requiresLiveSession).toBe(true);
const [item] = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
});
expect(
(item as unknown as { dialog: { requiresLiveSession?: boolean } }).dialog
.requiresLiveSession,
).toBe(true);
});
});
});
@@ -2,6 +2,7 @@ import type {
EditorAssetLibrarySnapshot,
EditorCharacterAnimationGenerationResult,
EditorProjectLayerSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasAssetKind,
@@ -500,6 +501,39 @@ function isCanvasSettingsLayoutItem(
return item.itemType === 'canvas-settings';
}
/**
* 中文注释:剥离「只能由已死会话收口」的 generating 占位。
*
* 判据是一条结构性不变量,不是时间阈值:置位了 requiresLiveSession 的占位,其收口只能由
* 创建它的页面会话完成;而活着的那一份始终在内存里,永远不经过快照 hydrate。因此凡是从
* 服务端快照里读回来的这类 generating 占位,创建它的会话必然已经不在了。
*
* 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用,
* 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。
*/
export function dropDeadInlineGenerationPlaceholders(
project: EditorProjectSnapshot,
): { project: EditorProjectSnapshot; droppedCount: number } {
const nextLayers = project.layers.filter((item) => {
if (item.itemType !== 'generation-dialog') {
return true;
}
const dialog =
(item as { dialog?: unknown }).dialog &&
typeof (item as { dialog?: unknown }).dialog === 'object'
? ((item as { dialog?: unknown }).dialog as Record<string, unknown>)
: null;
return !(
dialog?.requiresLiveSession === true && dialog?.status === 'generating'
);
});
const droppedCount = project.layers.length - nextLayers.length;
if (droppedCount === 0) {
return { project, droppedCount: 0 };
}
return { project: { ...project, layers: nextLayers }, droppedCount };
}
export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
@@ -569,6 +603,9 @@ export function hydrateCanvasGenerationDialog(
mode: snapshot.mode,
prompt,
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
// 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复,
// 不会被 dropDeadInlineGenerationPlaceholders 误清。
requiresLiveSession: snapshot.requiresLiveSession === true ? true : undefined,
composerOpen:
typeof snapshot.composerOpen === 'boolean'
? snapshot.composerOpen
@@ -237,6 +237,11 @@ export type GenerateDialogState = {
aspectRatio?: string;
imageSize?: string;
errorMessage?: string;
// 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有
// durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
// 必须原样恢复。
requiresLiveSession?: boolean;
generationStartedAt?: number;
generationFinishedAt?: number;
placeholder?: {
@@ -1184,6 +1184,7 @@ export function ImageCanvasEditorView({
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
deadInlinePlaceholderDropCount,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
@@ -1497,6 +1498,17 @@ export function ImageCanvasEditorView({
generationSurface.refreshTaskList();
}, [generationSurface]);
const showGenerationWarning = generationSurface.showGenerationWarning;
useEffect(() => {
if (deadInlinePlaceholderDropCount === 0) {
return;
}
// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset
// → 画布收口,非事务。加载时还看到 generating 只能说明最后一步没做完,前面几步可能已经
// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。
showGenerationWarning(
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。',
);
}, [deadInlinePlaceholderDropCount, showGenerationWarning]);
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
@@ -255,6 +255,12 @@ function GenerationWorkflowHarness({
)
.join('|') || '-'}
</span>
<span data-testid="live-session-dialogs">
{dialogs.canvasGenerationDialogs
.filter((dialog) => dialog.requiresLiveSession === true)
.map((dialog) => dialog.id)
.join('|') || '-'}
</span>
<span data-testid="generation-references">
{activeDialog?.generationReferences
?.map((reference) => reference.label)
@@ -2141,6 +2147,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('generation-dialogs').textContent).toContain(
'generation-dialog-1:quick-edit:closed',
);
// 中文注释:去除背景恒返回 queueState,job 在服务端继续跑,刷新后 worker 会替换占位。
// 置位 requiresLiveSession 会让加载期把还在跑的占位清掉,因此必须保持未置位。
expect(screen.getByTestId('live-session-dialogs').textContent).toBe('-');
expect(removeImageBackgroundMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'resource-source',
@@ -2240,6 +2249,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(events).toEqual(['flush', 'post']);
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
// 中文注释:完美像素是同步 HTTP、服务端无 durable job,占位的收口只能由本页会话完成。
// 标记必须真的落在提交出去的那份占位上——它会随 flush 一起持久化,是刷新后唯一能把
// 孤儿占位和队列型占位区分开的依据。漏置位就退回「刷新后永久转圈」。
expect(screen.getByTestId('live-session-dialogs').textContent).toBe(
'generation-dialog-1',
);
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'generated-images/editor/source.png',
@@ -1800,6 +1800,11 @@ export function useImageCanvasGenerationWorkflow({
},
}),
composerOpen: false,
// 中文注释:完美像素是同步 HTTP,服务端不建 durable job(见 snap_editor_image_to_pixel_art
// 无 enqueue / spawn)。刷新掉本页会话后,没有任何东西会把这个占位推向终态,而
// 服务端 409 门禁又要求占位必须先落库,所以只能在读取侧收口:置位后由项目首次
// 加载时的 dropDeadInlineGenerationPlaceholders 清掉。
requiresLiveSession: true,
});
perfectPixelDialogId = placement.dialogId;
if (!placement.placeholder) {
@@ -22,6 +22,7 @@ import {
canvasDisplayViewportToViewport,
type CanvasLayerResourceMetadata,
DEFAULT_CANVAS_BACKGROUND_COLOR,
dropDeadInlineGenerationPlaceholders,
hydrateLayer,
isInlineEditorMediaSource,
serializeCanvasLayout,
@@ -430,6 +431,10 @@ export function useImageCanvasProjectPersistence({
const coverSnapshotViewportSizeRef = useRef(canvasSize);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
// 中文注释:累计而不是布尔——同一次会话里可能连着切换多个项目,每个都可能留有孤儿占位,
// 布尔只会提示一次。调用方以计数变化为触发条件。
const [deadInlinePlaceholderDropCount, setDeadInlinePlaceholderDropCount] =
useState(0);
const {
setProjectTitle,
setProjectRenameValue,
@@ -1193,17 +1198,31 @@ export function useImageCanvasProjectPersistence({
currentUserId,
);
if (cachedProject) {
applyProjectSnapshot(cachedProject.project, { authoritative: false });
// 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位,
// 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。
applyProjectSnapshot(
dropDeadInlineGenerationPlaceholders(cachedProject.project).project,
{ authoritative: false },
);
}
const loadProject = projectIdFromQuery
? loadEditorProject(projectIdFromQuery)
: loadOrCreateRecentEditorProject();
loadProject
.then((project) => {
.then((loadedProject) => {
if (cancelled) {
return;
}
// 中文注释:只在这里剥离。会话内 applyQueuedEditorGenerationProject 也会重新 GET
// 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。
const { project, droppedCount } =
dropDeadInlineGenerationPlaceholders(loadedProject);
if (droppedCount > 0) {
setDeadInlinePlaceholderDropCount(
(currentCount) => currentCount + droppedCount,
);
}
const projectIsAuthoritative = applyProjectSnapshot(project, {
allowProjectSwitch: true,
});
@@ -1327,5 +1346,6 @@ export function useImageCanvasProjectPersistence({
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
deadInlinePlaceholderDropCount,
};
}