修复重复生成占位的对账判定
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Repository checks (pull_request) Successful in 1m8s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Native shell tests (pull_request) Successful in 10m53s

收集项目快照中全部同 ID 的生成占位原始记录。

通用队列链在任一重复占位未收口时执行第二次项目读取。

完美像素遇到重复操作占位时失败关闭为冲突。

补充两条缺陷定向测试并同步技术方案与决策记录。
This commit is contained in:
2026-08-04 05:46:51 +00:00
parent f6628ead7e
commit 06d8fbb9c2
7 changed files with 68 additions and 13 deletions
@@ -6093,7 +6093,7 @@
- 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items``status: "idle"``composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。
- 逃过测试的原因要单独记:那条「未知但实际成功」的用例夹具写的是 `layers: []`,是服务端永远不会产生的形状。**测试不是漏了,是主动为错误判据背书**——用一个假前提把反向逻辑测成了正确的。修复顺序因此定为「先改夹具、看它变红、再改判据」,让这件事显式暴露一次而不是被新判据顺手掩盖。
- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;提取为 `findCanvasGenerationDialogRecord` + `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydratehydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。
- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;原始 record 查询现由收集全部同 ID 记录的 `findCanvasGenerationDialogRecords` `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydratehydrate 会给缺失 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` 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,变成与本条镜像的反向谎报。
@@ -6221,3 +6221,10 @@
- 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。
- 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint``npm run check:encoding``git diff --check` 通过。未追加其它测试或全量测试套件。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-04 项目快照对账不再按重复 dialog ID 首项短路
- 缺陷:项目快照查询 helper 只返回第一个同 ID generation dialog。legacy / External API 画布若含重复 ID,首条已收口、后条仍 unresolved 时,通用 queued completion 会漏掉用于收口的第二次 GET;完美像素还可能把不满足后端唯一性契约的快照误判为成功。
- 决策:快照 helper 返回全部同 ID 原始记录。通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不按其中任意一条猜测结果。无需改变 hydrate、删除、后端或 OpenAPI。
- 验证:两条 `duplicate` 定向用例通过;`npm run typecheck`、改动文件级 ESLint 与 `npm run check:encoding` 通过,未运行目录或全仓测试套件。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -55,6 +55,7 @@
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstonecompletion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
- 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。
- 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。
### 角色动作帧抠图像素边界
@@ -791,19 +791,20 @@ function isCanvasSettingsLayoutItem(
}
/**
* 中文注释:从快照里取出指定 id 的生成占位原始记录,取不到返回 null
* 中文注释:从快照里取出指定 id 的全部生成占位原始记录,取不到返回空数组
*
* 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值
* (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。
*/
export function findCanvasGenerationDialogRecord(
export function findCanvasGenerationDialogRecords(
project: EditorProjectSnapshot,
dialogId: string | null | undefined,
): Record<string, unknown> | null {
): Record<string, unknown>[] {
const normalizedDialogId = dialogId?.trim();
if (!normalizedDialogId) {
return null;
return [];
}
const matchingDialogs: Record<string, unknown>[] = [];
for (const item of project.layers) {
if (item.itemType !== 'generation-dialog') {
continue;
@@ -814,10 +815,10 @@ export function findCanvasGenerationDialogRecord(
? ((item as { dialog?: unknown }).dialog as Record<string, unknown>)
: null;
if (dialog?.id === normalizedDialogId) {
return dialog;
matchingDialogs.push(dialog);
}
}
return null;
return matchingDialogs;
}
/**
@@ -2009,7 +2009,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => {
it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => {
vi.useFakeTimers();
try {
const applyProjectSnapshot = vi.fn();
@@ -2020,6 +2020,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
title: '队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'generation-dialog-background-removal-completed',
resourceId: 'generation-dialog-background-removal-completed',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'idle',
generatedLayerId: 'layer-background-removal-result',
},
},
{
layerId: 'generation-dialog-background-removal',
resourceId: 'generation-dialog-background-removal',
@@ -30,7 +30,7 @@ import {
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
import {
findCanvasGenerationDialogRecord,
findCanvasGenerationDialogRecords,
isUnresolvedCanvasGenerationDialogRecord,
} from './ImageCanvasEditorModel';
import type {
@@ -456,8 +456,8 @@ function projectHasUnresolvedGenerationDialog(
project: EditorProjectSnapshot,
dialogId: string | null | undefined,
) {
return isUnresolvedCanvasGenerationDialogRecord(
findCanvasGenerationDialogRecord(project, dialogId),
return findCanvasGenerationDialogRecords(project, dialogId).some(
isUnresolvedCanvasGenerationDialogRecord,
);
}
@@ -1254,6 +1254,34 @@ describe('useImageCanvasGenerationWorkflow', () => {
).toMatchObject({ kind: 'pending', project });
});
it('rejects duplicate perfect-pixel dialog ids even when the first record appears applied', () => {
const operationId = 'perfect-pixel-duplicate-dialog';
const project = createPerfectPixelProject(operationId, 'applied');
project.layers.push({
itemType: 'generation-dialog',
layerId: `generation-dialog:${operationId}:duplicate`,
resourceId: `generation-dialog:${operationId}:duplicate`,
dialog: {
id: operationId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
composerOpen: false,
},
});
expect(
inspectPerfectPixelProjectSnapshot(project, {
operationId,
taskId: `pixel-art-snap-${operationId}`,
}),
).toMatchObject({
kind: 'conflict',
project,
message: '权威项目中存在重复的完美像素占位,无法自动确认结果。',
});
});
it('requires a matching task resource before a missing dialog can prove asset-only success', () => {
const operationId = 'perfect-pixel-missing-dialog';
const matchingProject = createPerfectPixelProject(
@@ -29,7 +29,7 @@ import {
} from '../../services/image-editor/editorProjectClient';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import {
findCanvasGenerationDialogRecord,
findCanvasGenerationDialogRecords,
isUnresolvedCanvasGenerationDialogRecord,
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
} from './ImageCanvasEditorModel';
@@ -267,7 +267,7 @@ export function inspectPerfectPixelProjectSnapshot(
const matchingResources = project.resources.filter(
(resource) => resource.taskId?.trim() === operation.taskId,
);
const dialog = findCanvasGenerationDialogRecord(
const matchingDialogs = findCanvasGenerationDialogRecords(
project,
operation.operationId,
);
@@ -279,6 +279,14 @@ export function inspectPerfectPixelProjectSnapshot(
message: '权威项目中存在重复的完美像素任务资源,无法自动确认结果。',
};
}
if (matchingDialogs.length > 1) {
return {
kind: 'conflict',
project,
message: '权威项目中存在重复的完美像素占位,无法自动确认结果。',
};
}
const dialog = matchingDialogs[0] ?? null;
const resource = matchingResources[0] ?? null;
if (!resource) {
if (!dialog || isUnresolvedCanvasGenerationDialogRecord(dialog)) {