完美像素持久化阶段由服务端显式告知客户端
Project CI / Frontend tests (pull_request) Failing after 22s
Project CI / Repository checks (pull_request) Failing after 55s
Project CI / Backend tests (pull_request) Successful in 3m18s
Project CI / Native shell tests (pull_request) Failing after 7m37s

上一版用 error instanceof ApiClientError 判定「结果已知」,即「服务端响应过就等于
没落库」。这个等式不成立:持久化非事务,complete_editor_canvas_generation 走 CAS
写入,冲突时经 map_editor_project_error 变成 409,403/404/400 同理。带响应的 4xx
同样可能发生在 OSS 对象、asset object、project resource 和账号素材全部落库之后。
完美像素要跑满 30 秒,用户在这期间改画布推进 revision 并不罕见。

否决了两个替代方案:所有 POST 后失败都当未知(每次常见校验失败多两次读取,且给
不可能产生素材的场景附上「请核对素材库」的不适用提示);按状态码分类(409 确实只
来自写操作,但 403/404 和 5xx 在持久化前后都会出现,等于把猜测写进代码)。

改为服务端显式告知。AppError 新增 with_detail_field,在已有 details 上补字段而不是
整体替换,保留下游写入的 provider/message。snap_editor_image_to_pixel_art 在第一次
OSS PUT 之后的四条失败路径置 resultPersistenceStarted。客户端只对完全无响应和带该
标记的失败做对账。

配套:对账成功分支补上 hasCanvasGenerationDialogById 检查——权威快照里占位消失也
可能是用户删的,契约要求此时不应用快照不写历史,成功路径早有这道检查而对账路径漏了。
对账同时刷新素材库,否则只 GET 项目却让用户核对素材库,他看到的是旧列表。

服务端 guard 把标记钉为 4 处并要求只出现在 persist_editor_generated_image_owned
之后;客户端新增三条用例覆盖带标记对账、未带标记不对账、用户删除占位。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 12:51:12 +00:00
parent adfe980110
commit 43a4e7ef78
8 changed files with 253 additions and 44 deletions
@@ -1490,6 +1490,7 @@ export function ImageCanvasEditorView({
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
});
const handleEditorAgentConfirmSent = useCallback(() => {
@@ -107,6 +107,7 @@ type ImageCanvasGenerationSurfaceOptions = {
action?: CanvasHistoryAction,
) => void;
flushProjectPersistence?: () => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -184,6 +185,7 @@ export function useImageCanvasGenerationSurface({
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged,
}: ImageCanvasGenerationSurfaceOptions) {
const toolbarOptionCloseTimerRef = useRef<ReturnType<
@@ -221,6 +223,7 @@ export function useImageCanvasGenerationSurface({
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged,
});
@@ -125,6 +125,7 @@ function GenerationWorkflowHarness({
currentUserId,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
upsertGeneratedAsset,
}: {
initialLayers?: CanvasLayer[];
@@ -134,6 +135,9 @@ function GenerationWorkflowHarness({
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['applyProjectSnapshot'];
refreshAssetLibrary?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['refreshAssetLibrary'];
flushProjectPersistence?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['flushProjectPersistence'];
@@ -189,6 +193,7 @@ function GenerationWorkflowHarness({
currentUserId,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
upsertGeneratedAsset,
});
@@ -2368,12 +2373,10 @@ describe('useImageCanvasGenerationWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
expect(screen.getByTestId('dialog-error').textContent).toContain(
'结果未知',
);
expect(screen.getByTestId('dialog-error').textContent).toContain(
'请先确认再决定是否重试',
);
const unresolvedMessage =
screen.getByTestId('dialog-error').textContent ?? '';
expect(unresolvedMessage).toContain('画布未收到完美像素结果。');
expect(unresolvedMessage).toContain('素材库已刷新');
});
it('does not reconcile when the perfect-pixel request never left the client', async () => {
@@ -2409,18 +2412,138 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('dialog-error').textContent).toBe('画布保存失败');
});
it('keeps a rejected perfect-pixel request out of the reconciliation path', async () => {
// 中文注释:服务端明确响应过(ApiClientError)就是已知结果,不需要也不应该
// 再发对账 GET,否则每个 400 都要多打一次权威读取。
it('reconciles a responded failure that the server marked as post-persistence', async () => {
// 中文注释:服务端持久化非事务,completion 失败经 map_editor_project_error 变成 4xx,
// 状态码分不出阶段。服务端置 resultPersistenceStarted 后客户端才对账。
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
dialog: {
id: 'generation-dialog-1',
mode: 'quick-edit',
status: 'generating',
prompt: '完美像素',
},
},
],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new ApiClientError({
message: '画布版本冲突。',
status: 409,
code: 'HTTP_409',
// 中文注释:服务端在第一次 OSS PUT 之后的失败路径上置位该字段。
details: { resultPersistenceStarted: true },
}),
);
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
refreshAssetLibrary={refreshAssetLibrary}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
// 中文注释:契约要求核对「项目 / 素材」两份快照,素材库是独立记录。
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
expect(applyProjectSnapshot).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
const message = screen.getByTestId('dialog-error').textContent ?? '';
// 中文注释:服务端原文保留便于定位,对账结论与核对提示附加在后。
expect(message).toContain('画布版本冲突。');
expect(message).toContain('画布未收到完美像素结果。');
expect(message).toContain('素材库已刷新');
});
it('skips reconciliation for a responded failure the server did not mark', async () => {
// 中文注释:纯校验失败发生在任何 IO 之前,不可能留下对象或素材。服务端不置
// resultPersistenceStarted,客户端就不该多打两次读取,也不该附上「请核对素材库」
// 这种不适用的提示。
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new ApiClientError({
message: 'assetKind 与来源素材权威类型不一致。',
status: 400,
code: 'HTTP_400',
details: { provider: 'pixel-art-snapper', field: 'assetKind' },
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
refreshAssetLibrary={refreshAssetLibrary}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(refreshAssetLibrary).not.toHaveBeenCalled();
expect(applyProjectSnapshot).not.toHaveBeenCalled();
// 中文注释:文案保持服务端原文,不追加对账结论。
expect(screen.getByTestId('dialog-error').textContent).toBe(
'assetKind 与来源素材权威类型不一致。',
);
});
it('drops a reconciled perfect-pixel result when the user deleted the placeholder', async () => {
// 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉,或者用户在
// 请求期间主动删除。后者契约要求不应用完成快照、不写历史,删除意图胜出。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const timeoutError = new Error('The operation timed out.');
timeoutError.name = 'TimeoutError';
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
// 中文注释:请求在途时用户删掉占位。
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
throw timeoutError;
});
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
@@ -2438,13 +2561,15 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog-error').textContent).toBe(
'assetKind 与来源素材权威类型不一致。',
);
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
@@ -633,6 +633,7 @@ type GenerationWorkflowOptions = {
action?: CanvasHistoryAction,
) => void;
flushProjectPersistence?: () => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -668,6 +669,7 @@ export function useImageCanvasGenerationWorkflow({
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged,
}: GenerationWorkflowOptions) {
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
@@ -1851,22 +1853,33 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
// 中文注释:ApiClientError 只在拿到服务端 Response 时构造(apiClient 的
// buildApiClientError),transport 异常、abort 和 120 秒超时会原样抛出。
// 前者结果已知,后者未知——服务端可能已经完成 OSS PUT、asset object、
// project resource、账号素材和画布回填,只是响应没回来。该路由是 unsafe
// POST 且禁用自动重放,所以未知时必须先 GET 权威快照对账再决定文案:直接
// 标 failed 会谎报结果,用户重试就再造一份对象、资源和素材。
// 中文注释:只有真正发出过 POST 才谈得上「结果未知」。占位创建、源图解析和
// flush 都在 POST 之前,它们失败时请求根本没发出去,此时说「素材库可能已存在
// 派生图」是反向谎报,和这次要修的那个谎报是镜像关系。
const outcomeIsUnknown =
perfectPixelPostAttempted && !(error instanceof ApiClientError);
// 中文注释:只有真正发出过 POST 才谈得上「结果可能已落库」。占位创建、源图解析
// 和 flush 都在 POST 之前,它们失败时请求根本没发出去,此时提示核对素材库是反向
// 谎报。
//
// 一旦发出过 POST,就不能只用「服务端有没有响应」判定结果是否已知。服务端持久化
// 是非事务的(OSS + asset object → project resource → editor asset → canvas
// completion),而 completion 失败走 map_editor_project_error 会被映射成
// 403 / 404 / 409 / 400,带响应的 4xx 同样可能发生在对象、资源和账号素材全部落库
// 之后。状态码分不出持久化阶段,所以服务端在第一次 OSS PUT 之后的失败路径上置
// `resultPersistenceStarted`,客户端只对这类失败和完全无响应的失败做对账——常见的
// 纯校验 400 / 排队 503 / 预算 504 不会白白多两次读取,也不会被附上不适用的提示。
const persistenceMayHaveStarted =
error instanceof ApiClientError &&
(error.details as { resultPersistenceStarted?: unknown } | null)
?.resultPersistenceStarted === true;
const outcomeMayBePersisted =
perfectPixelPostAttempted &&
Boolean(perfectPixelDialogId) &&
(!(error instanceof ApiClientError) || persistenceMayHaveStarted);
let reconciledMessage: string | undefined;
if (outcomeIsUnknown && perfectPixelDialogId) {
const reconciled = await loadEditorProject(normalizedProjectId).catch(
() => null,
);
if (outcomeMayBePersisted && perfectPixelDialogId) {
// 中文注释:契约要求核对「项目 / 素材」两份快照。素材库是独立记录,只 GET
// 项目却让用户去核对素材库,他看到的仍是旧列表。
const [reconciled] = await Promise.all([
loadEditorProject(normalizedProjectId).catch(() => null),
Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined),
]);
if (reconciled) {
const placeholderSurvived = reconciled.layers.some(
(item) =>
@@ -1875,8 +1888,13 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelDialogId,
);
if (!placeholderSurvived) {
// 中文注释:占位已被服务端 completion 消费掉,说明这次其实成功了。
// 按权威快照收口并写入正常的完美像素历史,不再报错。
// 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉,
// 或者用户在请求期间主动删除。必须再查本地:本地占位也没了就是用户删的,
// 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有
// 这道检查,对账路径不能漏。
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
return;
}
applyProjectSnapshot(reconciled, {
type: 'perfect-pixel',
count: 1,
@@ -1890,20 +1908,21 @@ export function useImageCanvasGenerationWorkflow({
// 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受
// 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白
// 覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定而不是同步。
// 持久化是非事务的,OSS 对象与账号素材仍可能已落库,所以文案必须让用户先
// 去核对而不是直接重试。
reconciledMessage =
'完美像素结果未知:已核对权威快照,画布未收到结果。素材库可能已存在派生图,请先确认再决定是否重试。';
reconciledMessage = '画布未收到完美像素结果。';
} else {
reconciledMessage =
'完美像素结果未知,且权威快照读取失败。请刷新后确认素材库与画布,再决定是否重试。';
reconciledMessage = '权威项目快照读取失败,无法确认完美像素结果。';
}
}
const errorMessage =
reconciledMessage ??
(error instanceof Error && error.message.trim()
const serverMessage =
error instanceof Error && error.message.trim()
? error.message
: '完美像素处理失败');
: undefined;
// 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。
// 持久化非事务,对象与账号素材可能已落库而只有画布回填没完成,所以必须让用户先核对
// 素材库再决定,而不是直接重试。
const errorMessage = reconciledMessage
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}素材库已刷新,如已生成派生图请勿重复执行。`
: (serverMessage ?? '完美像素处理失败');
if (
perfectPixelDialogId &&
hasCanvasGenerationDialogById(perfectPixelDialogId)
@@ -1935,6 +1954,7 @@ export function useImageCanvasGenerationWorkflow({
flushProjectPersistence,
hasCanvasGenerationDialogById,
openPlacedCanvasGenerationDialog,
refreshAssetLibrary,
projectId,
setActiveSidebarPanel,
setActiveTool,