From 925238e22152560ab0d140c9a4d4c7fdd66a008a Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 3 Aug 2026 09:44:38 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E7=BE=8E=E5=83=8F=E7=B4=A0=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E6=94=B9=E7=9C=8B=20dialog=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E7=8A=B6=E6=80=81=EF=BC=8C=E7=BD=91=E5=85=B3=E5=90=88=E6=88=90?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BD=92=E5=85=A5=E6=9C=AA=E7=9F=A5=E7=BB=93?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对账原先用「同 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 --- .../shared-memory/decision-log.md | 12 ++ .../image-editor/ImageCanvasEditorModel.ts | 52 +++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 26 +-- .../useImageCanvasGenerationWorkflow.test.tsx | 194 +++++++++++++++++- .../useImageCanvasGenerationWorkflow.ts | 59 ++++-- src/services/apiClient.ts | 25 +++ 6 files changed, 330 insertions(+), 38 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b54244014..479c10cbc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 2ba543955..3d4fc307b 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -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 | 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) + : 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 | null, +): boolean { + if (!dialog) { + return false; + } + return ( + dialog.status === 'generating' || + typeof dialog.generatedLayerId !== 'string' || + dialog.generatedLayerId.trim() === '' + ); +} + /** * 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。 * diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index ff4df781d..4586bae1c 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -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) - : null; - return ( - dialog?.id === normalizedDialogId && - (dialog.status === 'generating' || - typeof dialog.generatedLayerId !== 'string' || - dialog.generatedLayerId.trim() === '') - ); - }); + return isUnresolvedCanvasGenerationDialogRecord( + findCanvasGenerationDialogRecord(project, dialogId), + ); } function notifyWalletBalanceMayHaveChanged(callback?: () => void) { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 63cc89982..12f1218c6 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 17e33039f..6e6d3be5a 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -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 会写一条类型错误且受 // 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白 diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 1c19e8f16..435fc4b34 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -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;