完美像素持久化阶段由服务端显式告知客户端
上一版用 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user