完美像素对账改看 dialog 收口状态,网关合成响应归入未知结果
对账原先用「同 ID dialog 是否还在权威快照里」判成败,而服务端成功回填时保留 该 dialog 并就地改写(置 status: idle、写 generatedLayerId),该行为另有服务端 测试钉住。判据因此反向:响应丢失但服务端已完成时,用户被告知「画布未收到结果」, 重做一遍就造出第二份,本地也看不到那个新图层。 逃过测试的原因是夹具写了 layers: [],服务端永远不会产生这个形状——测试不是漏了, 是主动为错误判据背书。修复顺序定为先改夹具、看它变红、再改判据。 判据改看 status / generatedLayerId,复用 projectHasUnresolvedGenerationDialog 的既有语义(提取为两个共享函数),不自创新判据。三态分别处置:dialog 不存在时 不再套用那份不含结果的快照。 第二处:Pingora 自造的 502/504 只有 code/message、没有 details,既不是 transport 异常也拿不到 resultPersistenceStarted,被当成确定失败跳过对账,而此时 api-server 可能已完成 OSS PUT。新增 isGatewayUnknownOutcomeError 放在 apiClient 层,只收上游 与代理三类;限流和体积拒绝仍算确定失败,否则退回反向谎报。 同时破坏两处修复后四条用例精确变红。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6084,3 +6084,15 @@
|
||||
- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
|
||||
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。
|
||||
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。
|
||||
|
||||
## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果
|
||||
|
||||
- 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。
|
||||
- 逃过测试的原因要单独记:那条「未知但实际成功」的用例夹具写的是 `layers: []`,是服务端永远不会产生的形状。**测试不是漏了,是主动为错误判据背书**——用一个假前提把反向逻辑测成了正确的。修复顺序因此定为「先改夹具、看它变红、再改判据」,让这件事显式暴露一次而不是被新判据顺手掩盖。
|
||||
- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;提取为 `findCanvasGenerationDialogRecord` + `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydrate:hydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。
|
||||
- 三态处置:dialog 不存在 → 占位在处理期间被删(本会话或另一标签页),completion 返回 `Ok(None)`,资源与素材已落库但快照不含结果图层,清本地占位并提示素材库,**不套用快照**(此前会套用一份不含结果的快照并写受撤销保护的历史,用户既看不到结果也撤不回);dialog 在且未收口 → 画布确实没收到,只给文案不同步;dialog 在且已收口 → 真成功,套用快照并写 `perfect-pixel` 历史。前两态都保留「用户已删本地占位则删除意图胜出」的检查。
|
||||
- 缺陷二(网关合成响应被当确定失败):分类前提是「拿到 `ApiClientError` ⇒ 服务端明确表态过 ⇒ 结果已知」。该前提对 Pingora 自造的错误体不成立——它只有 `code` / `message`、没有 `details`,因此既不是 transport 异常也拿不到 `resultPersistenceStarted`,直接跳过对账;而 `ConnectTimedout / ReadTimedout / WriteTimedout → 504`、`ErrorSource::Upstream → 502` 都可能发生在 api-server 已完成 OSS PUT 之后。
|
||||
- 决策二:新增 `isGatewayUnknownOutcomeError`,放在 `services/apiClient.ts` 而不是 image-editor——网关在所有接口前面,任何有副作用的 inline 写接口都有同一问题。只收 `GATEWAY_UPSTREAM_ERROR` / `GATEWAY_UPSTREAM_TIMEOUT` / `GATEWAY_PROXY_ERROR` 三类。`GATEWAY_RATE_LIMITED` / `GATEWAY_CONCURRENCY_LIMITED` / `PAYLOAD_TOO_LARGE` 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,变成与本条镜像的反向谎报。
|
||||
- 两条共性:都是用代理信号代替事实——用「占位在不在」代替「操作完成没有」,用「有没有 HTTP 响应」代替「应用层有没有表态」。两处都是没有去读被代理的那个事实的真实形状。
|
||||
- 验证:新增四条用例(网关 504 触发对账、网关 429 不触发、应用层无标记 502 不触发、快照无 dialog 时不套用快照)。同时破坏两处修复后,四条精确变红。后两条是对照用例,专门守住「放宽判据不得退回反向谎报」。`vitest src/components/image-editor` 907 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -501,6 +501,58 @@ function isCanvasSettingsLayoutItem(
|
||||
return item.itemType === 'canvas-settings';
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:从快照里取出指定 id 的生成占位原始记录,取不到返回 null。
|
||||
*
|
||||
* 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值
|
||||
* (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。
|
||||
*/
|
||||
export function findCanvasGenerationDialogRecord(
|
||||
project: EditorProjectSnapshot,
|
||||
dialogId: string | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
const normalizedDialogId = dialogId?.trim();
|
||||
if (!normalizedDialogId) {
|
||||
return null;
|
||||
}
|
||||
for (const item of project.layers) {
|
||||
if (item.itemType !== 'generation-dialog') {
|
||||
continue;
|
||||
}
|
||||
const dialog =
|
||||
(item as { dialog?: unknown }).dialog &&
|
||||
typeof (item as { dialog?: unknown }).dialog === 'object'
|
||||
? ((item as { dialog?: unknown }).dialog as Record<string, unknown>)
|
||||
: null;
|
||||
if (dialog?.id === normalizedDialogId) {
|
||||
return dialog;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:占位是否仍未收口。这是本仓库对「这个生成完成了没有」的既有定义,原先只存在于
|
||||
* `useImageCanvasGenerationSubmissionWorkflow` 的队列轮询里,现在提取共用。
|
||||
*
|
||||
* 判据必须是 status / generatedLayerId,**不能**是「占位还在不在」。服务端成功回填时会保留
|
||||
* 该 dialog 并就地改写(`editor_project.rs` 的 `apply_editor_canvas_generation_items`:置
|
||||
* `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`),
|
||||
* 该行为另有服务端测试钉住。按「在不在」判会把真成功判成失败。
|
||||
*/
|
||||
export function isUnresolvedCanvasGenerationDialogRecord(
|
||||
dialog: Record<string, unknown> | null,
|
||||
): boolean {
|
||||
if (!dialog) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
dialog.status === 'generating' ||
|
||||
typeof dialog.generatedLayerId !== 'string' ||
|
||||
dialog.generatedLayerId.trim() === ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。
|
||||
*
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
generateEditorVideo,
|
||||
loadEditorProject,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
findCanvasGenerationDialogRecord,
|
||||
isUnresolvedCanvasGenerationDialogRecord,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
@@ -417,25 +421,9 @@ function projectHasUnresolvedGenerationDialog(
|
||||
project: EditorProjectSnapshot,
|
||||
dialogId: string | null | undefined,
|
||||
) {
|
||||
const normalizedDialogId = dialogId?.trim();
|
||||
if (!normalizedDialogId) {
|
||||
return false;
|
||||
}
|
||||
return project.layers.some((item) => {
|
||||
if (item.itemType !== 'generation-dialog') {
|
||||
return false;
|
||||
}
|
||||
const dialog =
|
||||
item.dialog && typeof item.dialog === 'object'
|
||||
? (item.dialog as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
dialog?.id === normalizedDialogId &&
|
||||
(dialog.status === 'generating' ||
|
||||
typeof dialog.generatedLayerId !== 'string' ||
|
||||
dialog.generatedLayerId.trim() === '')
|
||||
);
|
||||
});
|
||||
return isUnresolvedCanvasGenerationDialogRecord(
|
||||
findCanvasGenerationDialogRecord(project, dialogId),
|
||||
);
|
||||
}
|
||||
|
||||
function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
|
||||
|
||||
@@ -2294,7 +2294,26 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
projectId: 'project-1',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
// 中文注释:服务端成功回填时**保留**该 dialog 并就地改写——
|
||||
// apply_editor_canvas_generation_items 置 status: "idle"、composerOpen: false、
|
||||
// 写入 generatedLayerId、清掉 errorMessage,该行为另有服务端测试钉住。
|
||||
// 此前这里写的是 layers: [],那是服务端永远不会产生的形状,等于用一个假前提去验证
|
||||
// 「占位不在即成功」这个反向判据,让「真成功被判成失败」的缺陷逃过了测试。
|
||||
layers: [
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: 'generation-dialog:generation-dialog-1',
|
||||
resourceId: 'generation-dialog:generation-dialog-1',
|
||||
dialog: {
|
||||
id: 'generation-dialog-1',
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'idle',
|
||||
composerOpen: false,
|
||||
generatedLayerId: 'layer-perfect-pixel-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
@@ -2591,6 +2610,179 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('treats a gateway-synthesized timeout as an unknown outcome and reconciles', async () => {
|
||||
// 中文注释:网关自己合成的 504 只有 code / message,没有 details,所以既不是 transport
|
||||
// 异常也拿不到 resultPersistenceStarted。按「有响应即结果已知」会跳过对账,而此时
|
||||
// api-server 可能已经走完 OSS PUT 与落库,用户重试就造出第二份。
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const reconciledProject = {
|
||||
projectId: 'project-1',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: 'generation-dialog:generation-dialog-1',
|
||||
resourceId: 'generation-dialog:generation-dialog-1',
|
||||
dialog: {
|
||||
id: 'generation-dialog-1',
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'idle',
|
||||
composerOpen: false,
|
||||
generatedLayerId: 'layer-perfect-pixel-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '上游服务请求超时',
|
||||
status: 504,
|
||||
code: 'GATEWAY_UPSTREAM_TIMEOUT',
|
||||
}),
|
||||
);
|
||||
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
|
||||
});
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, {
|
||||
type: 'perfect-pixel',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('still treats a gateway rate limit as a known failure', async () => {
|
||||
// 中文注释:对照用例。限流是在网关就被拒、根本没到应用,属于确定失败。把它也算未知会让
|
||||
// 普通节流弹出「请核对素材库」,正是本次要避免的反向谎报。
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '请求过于频繁',
|
||||
status: 429,
|
||||
code: 'GATEWAY_RATE_LIMITED',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:failed:closed:-:placeholder',
|
||||
);
|
||||
});
|
||||
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still treats an application upstream error without the marker as known', async () => {
|
||||
// 中文注释:对照用例。api-server 自己的 502 出现在第一次 OSS PUT **之前**(例如准备上传
|
||||
// 请求失败)时不带 resultPersistenceStarted,此时什么都没落库,触发对账会告诉用户去核对
|
||||
// 素材库——那是与本条镜像的反向谎报。
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '上游服务请求失败',
|
||||
status: 502,
|
||||
code: 'UPSTREAM_ERROR',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:failed:closed:-:placeholder',
|
||||
);
|
||||
});
|
||||
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a foreign-deleted placeholder out of the canvas after reconciliation', async () => {
|
||||
// 中文注释:权威快照里没有该 dialog——只可能是它在处理期间被删掉了(本会话或另一标签页),
|
||||
// 服务端 completion 因此返回 Ok(None):资源与素材已落库,但快照里没有结果图层。此时套用
|
||||
// 快照会写一条受撤销保护的 perfect-pixel 历史,而用户既看不到结果也撤不回。
|
||||
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 transportError = new Error('网络中断');
|
||||
snapImageToPerfectPixelsMock.mockRejectedValueOnce(transportError);
|
||||
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'完美像素结果已保存到素材库,画布占位已不存在。',
|
||||
);
|
||||
});
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
|
||||
const order: string[] = [];
|
||||
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
ApiClientError,
|
||||
isGatewayUnknownOutcomeError,
|
||||
} from '../../services/apiClient';
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
@@ -23,7 +26,10 @@ import {
|
||||
splitEditorIconSpritesheet,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import { isCanvasGenerationDialogLayoutItem } from './ImageCanvasEditorModel';
|
||||
import {
|
||||
findCanvasGenerationDialogRecord,
|
||||
isUnresolvedCanvasGenerationDialogRecord,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
@@ -1877,7 +1883,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const outcomeMayBePersisted =
|
||||
perfectPixelPostAttempted &&
|
||||
Boolean(perfectPixelDialogId) &&
|
||||
(!(error instanceof ApiClientError) || persistenceMayHaveStarted);
|
||||
// 中文注释:拿到 HTTP 响应不等于服务端明确表态过。Pingora 网关在上游超时或断连时
|
||||
// 会自己合成 502 / 504,那种响应只有 code / message、没有 details,因此既不是
|
||||
// transport 异常也拿不到 resultPersistenceStarted——按原判据会被当成确定失败,
|
||||
// 而此时 api-server 可能已经走完 OSS PUT 与落库。必须算作未知结果去对账。
|
||||
(!(error instanceof ApiClientError) ||
|
||||
persistenceMayHaveStarted ||
|
||||
isGatewayUnknownOutcomeError(error));
|
||||
let reconciledMessage: string | undefined;
|
||||
if (outcomeMayBePersisted && perfectPixelDialogId) {
|
||||
// 中文注释:契约要求核对「项目 / 素材」两份快照。素材库是独立记录,只 GET
|
||||
@@ -1893,22 +1905,33 @@ export function useImageCanvasGenerationWorkflow({
|
||||
Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined),
|
||||
]);
|
||||
if (reconciled) {
|
||||
const placeholderSurvived = reconciled.layers.some(
|
||||
(item) =>
|
||||
isCanvasGenerationDialogLayoutItem(item) &&
|
||||
(item as { dialog?: { id?: unknown } }).dialog?.id ===
|
||||
perfectPixelDialogId,
|
||||
// 中文注释:判据必须看 dialog 的 status / generatedLayerId,**不能**看它在不在。
|
||||
// 服务端成功回填时保留该 dialog 并就地改写(置 status: "idle"、写 generatedLayerId),
|
||||
// 按「在不在」判会把真成功判成失败——用户会被告知「画布未收到结果」,然后重做一遍
|
||||
// 造出第二份。三种服务端状态各有对应处置,见下。
|
||||
const reconciledDialog = findCanvasGenerationDialogRecord(
|
||||
reconciled,
|
||||
perfectPixelDialogId,
|
||||
);
|
||||
if (!placeholderSurvived) {
|
||||
// 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉,
|
||||
// 或者用户在请求期间主动删除。必须再查本地:本地占位也没了就是用户删的,
|
||||
// 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有
|
||||
// 这道检查,对账路径不能漏。
|
||||
if (!reconciledDialog) {
|
||||
// 中文注释:占位在权威快照里不存在——只可能是它在处理期间被删掉了(本会话或另一
|
||||
// 标签页),服务端 complete_editor_canvas_generation 因此返回 Ok(None):资源与
|
||||
// 账号素材已落库,但画布上没有结果图层。不能套用这份快照当成功,它根本不含结果。
|
||||
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
updateCanvasGenerationDialogById(
|
||||
perfectPixelDialogId,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
showGenerationWarning(
|
||||
'完美像素结果已保存到素材库,画布占位已不存在。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isUnresolvedCanvasGenerationDialogRecord(reconciledDialog)) {
|
||||
// 中文注释:服务端已经完成并回填,只是响应没回来。这是真成功。
|
||||
// 但本地占位若已被用户删除,删除意图仍然胜出:不套用快照、不写历史。
|
||||
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
// 中文注释:不应用快照是对的(删除意图胜出),但不能连话都不说。走到这里
|
||||
// 意味着权威快照里占位已被 completion 消费——本分支下一步正是据此把结果
|
||||
// 当成功套用,所以结论一致:结果已落库。用户删掉的是占位而不是素材,必须
|
||||
// 告诉他素材库多了一份,否则他既看不到画布结果也不知道素材已生成。
|
||||
showGenerationWarning(
|
||||
'完美像素结果已保存到素材库,画布占位已不存在。',
|
||||
);
|
||||
@@ -1922,7 +1945,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setActiveSidebarPanel('layers');
|
||||
return;
|
||||
}
|
||||
// 中文注释:占位仍在,画布没收到结果。这里刻意不调 applyProjectSnapshot:
|
||||
// 中文注释:占位仍未收口,画布确实没收到结果。这里刻意不调 applyProjectSnapshot:
|
||||
// 传给本 hook 的是 ImageCanvasEditorView 的 applyGeneratedProjectSnapshot,
|
||||
// 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受
|
||||
// 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白
|
||||
|
||||
@@ -451,6 +451,31 @@ function resolveAuthFailurePolicy(
|
||||
};
|
||||
}
|
||||
|
||||
// 中文注释:这些错误体由 Pingora 网关自己合成,应用层从未产生过——它们只有 code / message,
|
||||
// 没有 details,所以任何「有 details 标记就是已知结果」的判定对它们都不成立。
|
||||
// 只收上游/代理这三类:请求可能已经到达 api-server 并被执行完,只是响应没回到客户端,
|
||||
// 属于未知结果。GATEWAY_RATE_LIMITED / GATEWAY_CONCURRENCY_LIMITED / PAYLOAD_TOO_LARGE
|
||||
// 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,
|
||||
// 变成反向谎报。
|
||||
const GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES = new Set([
|
||||
'GATEWAY_UPSTREAM_ERROR',
|
||||
'GATEWAY_UPSTREAM_TIMEOUT',
|
||||
'GATEWAY_PROXY_ERROR',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 中文注释:该错误是否由网关合成、因而无法证明应用层没有执行过这次请求。
|
||||
*
|
||||
* 有副作用的写接口在判断「结果是否已知」时必须把它算作未知:拿到 HTTP 响应不等于服务端
|
||||
* 明确表态过,网关超时或上游断连时 api-server 可能已经完成了持久化。
|
||||
*/
|
||||
export function isGatewayUnknownOutcomeError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES.has(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
|
||||
Reference in New Issue
Block a user