完美像素请求账本移出项目布局
账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在 用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局 保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。 改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留 perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是 同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证 ——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变 失败语义的 preferLatestGenerationDialogs。 由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历 内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清 账本。 本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、 配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户 删掉重来即可。跨设备不再自动收口是已知且接受的代价。 布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性 判死;写入侧不再产生新的内联账本。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,11 @@ import {
|
||||
type UiAssetExtractionTool,
|
||||
updateUiAssetExtractionDraftMark,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
import {
|
||||
forgetPerfectPixelOperation,
|
||||
readPerfectPixelOperations,
|
||||
savePerfectPixelOperation,
|
||||
} from './perfectPixelOperationStore';
|
||||
import {
|
||||
applyQueuedEditorGenerationProject,
|
||||
createEditorGenerationMediaUploadId,
|
||||
@@ -220,17 +225,15 @@ function createProjectLayerSnapshotFromLayer(
|
||||
};
|
||||
}
|
||||
|
||||
// 中文注释:源图解析/直传与 strict operation journal 使用两段独立预算。
|
||||
// 中文注释:源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整
|
||||
// 上传;confirm 成功后已经有稳定 objectKey,operation 请求随即形成。
|
||||
//
|
||||
// 源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整上传;confirm
|
||||
// 成功后已经有稳定 objectKey,operation 请求随即形成。此后 strict layout save 使用独立
|
||||
// 60 秒绝对 deadline,不能继续消耗源上传的余额,也不能在本地放弃等待后继续占住保存队列。
|
||||
// 这一段之前 POST 必须为零:失败时保留 `failed + perfectPixelOperation`,允许原样重试同一
|
||||
// request/source object,也不能执行结果 GET 或声称派生素材可能已落库。
|
||||
//
|
||||
// strict ACK 之前 POST 必须为零。此阶段失败保留 `failed + perfectPixelOperation`,只允许
|
||||
// 原样重试同一 request/source object;不能退回普通按钮创建新 identity,也不能执行结果 GET
|
||||
// 或声称派生素材可能已落库。
|
||||
// 账本落到本机(perfectPixelOperationStore)之后,这一段与 POST 之间不再有任何需要等待
|
||||
// 服务端 ACK 的环节——原先的严格布局保存预算随之删除。
|
||||
export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000;
|
||||
export const PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS = 60_000;
|
||||
|
||||
const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000;
|
||||
const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000];
|
||||
@@ -931,10 +934,7 @@ type GenerationWorkflowOptions = {
|
||||
project: EditorProjectSnapshot,
|
||||
) => boolean | void;
|
||||
flushProjectPersistence?: (options?: {
|
||||
requireSuccess?: boolean;
|
||||
requiredDialogId?: string;
|
||||
deadlineAt?: number;
|
||||
signal?: AbortSignal;
|
||||
preferLatestGenerationDialogs?: boolean;
|
||||
}) => Promise<void>;
|
||||
refreshAssetLibrary?: () => Promise<unknown> | void;
|
||||
onWalletBalanceMayHaveChanged?: () => void;
|
||||
@@ -2145,6 +2145,13 @@ export function useImageCanvasGenerationWorkflow({
|
||||
if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') {
|
||||
return false;
|
||||
}
|
||||
// 中文注释:项目事实已给出终态,账本再无用处。留着只会让下次加载多发一次无谓的
|
||||
// 对账 GET,并占住保留期名额。
|
||||
forgetPerfectPixelOperation(
|
||||
currentUserId,
|
||||
operation.request.projectId,
|
||||
operation.operationId,
|
||||
);
|
||||
const confirmedAsset = resolveConfirmedPerfectPixelAsset(
|
||||
result,
|
||||
operation,
|
||||
@@ -2177,6 +2184,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
},
|
||||
[
|
||||
applyProjectSnapshot,
|
||||
currentUserId,
|
||||
hasCanvasGenerationDialogById,
|
||||
refreshPerfectPixelAssetLibrary,
|
||||
setActiveSidebarPanel,
|
||||
@@ -2355,13 +2363,22 @@ export function useImageCanvasGenerationWorkflow({
|
||||
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||||
...dialog,
|
||||
requiresLiveSession: undefined,
|
||||
perfectPixelOperationId: perfectPixelDialogId,
|
||||
perfectPixelOperation,
|
||||
}));
|
||||
// 中文注释:账本必须先于 POST 落到本机。这一步是同步的、不过网络、不受服务端
|
||||
// 校验影响,因此它能提供「请求可被追溯」的保证,却不会像旧的严格布局保存那样
|
||||
// 把布局校验失败升级成完美像素的硬阻断。
|
||||
savePerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
perfectPixelOperation,
|
||||
);
|
||||
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
|
||||
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
|
||||
await flushProjectPersistence({
|
||||
requireSuccess: true,
|
||||
requiredDialogId: perfectPixelDialogId,
|
||||
deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
|
||||
});
|
||||
preferLatestGenerationDialogs: true,
|
||||
}).catch(() => undefined);
|
||||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||||
return;
|
||||
}
|
||||
@@ -2469,7 +2486,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const errorMessage = reconciledMessage
|
||||
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。`
|
||||
: perfectPixelOperation && !perfectPixelPostAttempted
|
||||
? `完美像素请求尚未发出:${serverMessage ?? '提交前画布保存失败'} 请在原占位重试同一操作。`
|
||||
? `完美像素请求尚未发出:${serverMessage ?? '提交前准备失败'} 请在原占位重试,或删除占位后重来。`
|
||||
: (serverMessage ?? '完美像素处理失败');
|
||||
if (
|
||||
perfectPixelDialogId &&
|
||||
@@ -2481,10 +2498,16 @@ export function useImageCanvasGenerationWorkflow({
|
||||
errorMessage,
|
||||
}));
|
||||
} else {
|
||||
// 中文注释:durable operation 的普通删除路径已被禁止,但占位仍可能在快照写入前
|
||||
// 失败、被其它权威项目更新移除,或来自旧会话状态。没有本地 dialog 可挂错误时必须
|
||||
// 退回全局提示,不能静默开放一条新 identity 的重提路径。
|
||||
// 中文注释:占位可能在快照写入前失败、被用户删除、被其它权威项目更新移除,或来自
|
||||
// 旧会话状态。没有本地 dialog 可挂错误时退回全局提示。
|
||||
showGenerationWarning(errorMessage);
|
||||
if (perfectPixelDialogId) {
|
||||
forgetPerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
perfectPixelDialogId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (perfectPixelDialogId) {
|
||||
@@ -2589,13 +2612,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
status: 'generating',
|
||||
errorMessage: undefined,
|
||||
requiresLiveSession: undefined,
|
||||
perfectPixelOperationId: normalizedDialogId,
|
||||
perfectPixelOperation: retriedOperation,
|
||||
}));
|
||||
savePerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
retriedOperation,
|
||||
);
|
||||
await flushProjectPersistence({
|
||||
requireSuccess: true,
|
||||
requiredDialogId: normalizedDialogId,
|
||||
deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
|
||||
});
|
||||
preferLatestGenerationDialogs: true,
|
||||
}).catch(() => undefined);
|
||||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||||
return;
|
||||
}
|
||||
@@ -2732,19 +2759,36 @@ export function useImageCanvasGenerationWorkflow({
|
||||
perfectPixelRecoveryControllersRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const recoverableDialogs = canvasGenerationDialogs.filter(
|
||||
(dialog) =>
|
||||
Boolean(dialog.perfectPixelOperation) &&
|
||||
(dialog.status === 'generating' ||
|
||||
dialog.status === 'pending-confirmation'),
|
||||
const recoverableOperations = canvasGenerationDialogs.flatMap((dialog) =>
|
||||
dialog.perfectPixelOperation &&
|
||||
(dialog.status === 'generating' ||
|
||||
dialog.status === 'pending-confirmation')
|
||||
? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }]
|
||||
: [],
|
||||
);
|
||||
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
|
||||
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
|
||||
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
|
||||
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
|
||||
const recoverableDialogIds = new Set(
|
||||
canvasGenerationDialogs.map((dialog) => dialog.id),
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
for (const [operationId, operation] of readPerfectPixelOperations(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
)) {
|
||||
if (
|
||||
recoverableDialogIds.has(operationId) ||
|
||||
operation.reconcileUntil <= nowMs
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
recoverableOperations.push({ dialogId: operationId, operation });
|
||||
}
|
||||
const recoveryKeys = new Set(
|
||||
recoverableDialogs.map((dialog) =>
|
||||
perfectPixelRecoveryKey(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
dialog.perfectPixelOperation!,
|
||||
),
|
||||
recoverableOperations.map(({ operation }) =>
|
||||
perfectPixelRecoveryKey(currentUserId, normalizedProjectId, operation),
|
||||
),
|
||||
);
|
||||
for (const [
|
||||
@@ -2757,8 +2801,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
}
|
||||
}
|
||||
|
||||
for (const dialog of recoverableDialogs) {
|
||||
const operation = dialog.perfectPixelOperation!;
|
||||
for (const { dialogId, operation } of recoverableOperations) {
|
||||
const recoveryKey = perfectPixelRecoveryKey(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
@@ -2766,7 +2809,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
// 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation
|
||||
// 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。
|
||||
if (hasActiveInlineGenerationDialog(dialog.id)) {
|
||||
if (hasActiveInlineGenerationDialog(dialogId)) {
|
||||
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
|
||||
continue;
|
||||
}
|
||||
@@ -2781,7 +2824,12 @@ export function useImageCanvasGenerationWorkflow({
|
||||
perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller);
|
||||
void (async () => {
|
||||
if (operation.request.projectId !== normalizedProjectId) {
|
||||
updateCanvasGenerationDialogById(dialog.id, (current) => {
|
||||
forgetPerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
operation.operationId,
|
||||
);
|
||||
updateCanvasGenerationDialogById(dialogId, (current) => {
|
||||
if (
|
||||
!isSameUnsettledPerfectPixelOperation(
|
||||
current,
|
||||
@@ -2817,15 +2865,23 @@ export function useImageCanvasGenerationWorkflow({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
|
||||
// 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。
|
||||
forgetPerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
operation.operationId,
|
||||
);
|
||||
}
|
||||
if (verdict.kind === 'applied') {
|
||||
if (!hasCanvasGenerationDialogById(dialog.id)) {
|
||||
if (!hasCanvasGenerationDialogById(dialogId)) {
|
||||
refreshPerfectPixelAssetLibrary();
|
||||
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
|
||||
return;
|
||||
}
|
||||
const applied = applyProjectSnapshotWithoutHistory(verdict.project);
|
||||
if (applied === false) {
|
||||
updateCanvasGenerationDialogById(dialog.id, (current) =>
|
||||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||||
isSameUnsettledPerfectPixelOperation(
|
||||
current,
|
||||
operation.operationId,
|
||||
@@ -2844,8 +2900,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
return;
|
||||
}
|
||||
if (verdict.kind === 'dialog-missing') {
|
||||
if (hasCanvasGenerationDialogById(dialog.id)) {
|
||||
updateCanvasGenerationDialogById(dialog.id, (current) =>
|
||||
if (hasCanvasGenerationDialogById(dialogId)) {
|
||||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||||
isSameUnsettledPerfectPixelOperation(
|
||||
current,
|
||||
operation.operationId,
|
||||
@@ -2858,7 +2914,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
|
||||
return;
|
||||
}
|
||||
updateCanvasGenerationDialogById(dialog.id, (current) =>
|
||||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||||
isSameUnsettledPerfectPixelOperation(current, operation.operationId)
|
||||
? {
|
||||
...current,
|
||||
@@ -2881,9 +2937,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
hasCanvasGenerationDialogById(dialog.id)
|
||||
hasCanvasGenerationDialogById(dialogId)
|
||||
) {
|
||||
updateCanvasGenerationDialogById(dialog.id, (current) =>
|
||||
updateCanvasGenerationDialogById(dialogId, (current) =>
|
||||
isSameUnsettledPerfectPixelOperation(
|
||||
current,
|
||||
operation.operationId,
|
||||
|
||||
Reference in New Issue
Block a user