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