完美像素持久化阶段由服务端显式告知客户端
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
@@ -5972,3 +5972,13 @@
- 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。
- 补充(同日):对账只对真正发出过 POST 的失败生效。占位创建、源图解析和 `flushProjectPersistence` 都在 POST 之前,它们失败时请求根本没发出,此时给出「素材库可能已存在派生图」是反向谎报,与本条要修的谎报是镜像关系;用 `perfectPixelPostAttempted` 标记划界,同时省掉一次无意义的权威读取。占位存活分支也不再调用 `applyProjectSnapshot`:传给该 hook 的是 `ImageCanvasEditorView``applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致(占位都在),套用只会覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定,不是同步。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-01 完美像素持久化阶段由服务端显式告知客户端
- 背景:上一版对账用 `error instanceof ApiClientError` 判定「结果已知」,即「服务端响应过就等于没落库」。这个等式不成立——持久化非事务,`complete_editor_canvas_generation` 走 CAS 写入,冲突时 `spacetime-module` 抛「图片画布版本冲突」,经 `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 上补字段而不是像 `with_details` 那样整体替换,保留下游写入的 provider / message——客户端要靠 message 定位、靠新字段决策。`snap_editor_image_to_pixel_art` 在第一次 OSS PUT 之后的四条失败路径(账号素材持久化失败、项目资源缺失、账号素材缺失、画布回填失败)置 `resultPersistenceStarted: true`。客户端只对「完全无响应」和「带该标记」的失败做对账,常见的纯校验 `400`、排队 `503`、预算 `504` 既不多打读取也不附加提示。
- 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。
- 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。
- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口,需先给 dialog 增加「无 durable job 的 inline 链路」标记,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
File diff suppressed because one or more lines are too long
@@ -161,6 +161,13 @@ const EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY: usize = 4;
/// 即无全局上限)时的连接风暴。它只防雪崩,不做流量整形。
const EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH: usize = 2048;
const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30);
/// 中文注释:完美像素持久化是非事务的(OSS + asset object → project resource →
/// editor asset → canvas completion)。后段失败时前段记录会保留,而 completion 走
/// `map_editor_project_error` 会被映射成 403 / 404 / 409 / 400,客户端从状态码分不出
/// 失败发生在持久化之前还是之后。这个 detail 字段就是那个信号:只在第一次 OSS PUT
/// 之后的失败路径上置位,客户端据此决定是否先 GET 权威快照对账,而不是直接标失败诱使
/// 用户重试再造一份对象、资源和素材。
pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted";
const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap";
const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel";
const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative";
@@ -4945,7 +4952,7 @@ pub async fn snap_editor_image_to_pixel_art(
error = %error,
"editor_pixel_art_snap_asset_persistence_failed_after_object_put"
);
error
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
})?;
let resource = generated_asset.resource.ok_or_else(|| {
tracing::warn!(
@@ -4958,6 +4965,7 @@ pub async fn snap_editor_image_to_pixel_art(
StatusCode::INTERNAL_SERVER_ERROR,
"完美像素结果未创建项目资源。",
)
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
})?;
let asset = generated_asset.asset.ok_or_else(|| {
tracing::warn!(
@@ -4971,6 +4979,7 @@ pub async fn snap_editor_image_to_pixel_art(
StatusCode::INTERNAL_SERVER_ERROR,
"完美像素结果未创建账号素材。",
)
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
})?;
// 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经
// 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。
@@ -4991,7 +5000,7 @@ pub async fn snap_editor_image_to_pixel_art(
error = %error,
"editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence"
);
error
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
})?;
Ok(json_success_body(
@@ -12460,6 +12469,29 @@ mod tests {
"resolve_editor_reference_object_key_for_owner(state",
],
);
// 中文注释:第一次 OSS PUT 之后的每条失败路径都必须带 resultPersistenceStarted。
// 服务端持久化非事务,completion 失败经 map_editor_project_error 会变成 4xx
// 客户端从状态码分不出失败在持久化前还是后;漏标一处,那条路径上的失败就会被前端
// 判成「结果已知」,用户重试再造一份对象、资源和素材。四处分别对应:账号素材持久化
// 失败、项目资源缺失、账号素材缺失、画布回填失败。
assert_function_occurrence_count(
source,
"pub async fn snap_editor_image_to_pixel_art(",
"async fn validate_editor_background_removal_source",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
4,
);
assert_function_contains_in_order(
source,
"pub async fn snap_editor_image_to_pixel_art(",
"async fn validate_editor_background_removal_source",
&[
// 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯
// 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。
"persist_editor_generated_image_owned(",
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
],
);
assert_function_not_contains(
source,
"pub async fn snap_editor_image_to_pixel_art(",
@@ -68,6 +68,24 @@ impl AppError {
self
}
/// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。
/// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留
/// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。
pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self {
match self.details.take() {
Some(Value::Object(mut object)) => {
object.insert(key.to_string(), value);
self.details = Some(Value::Object(object));
}
// 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象,
// 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。
_ => {
self.details = Some(serde_json::json!({ key: value }));
}
}
self
}
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
@@ -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,