完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时
处理预算只覆盖到规整为止,持久化阶段无界,仅受 OSS 客户端每请求 120 秒约束, PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长 超过 270 秒,远大于客户端 120 秒。客户端因此会在服务端仍在合法工作时 abort, 对账采样到仍在途的操作——占位还在、素材库还空,用户照提示核对什么也看不到, 重试就造出孤儿 OSS 对象。 给持久化整段套独立预算 60 秒,服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内 留 30 秒余量。独立起算而不与处理预算取 min:持久化已付出 OSS PUT 的代价,因 下载慢被砍预算、中途放弃只会留下孤儿,取 min 方向正好反了。 超时发生在 PUT 之后,对象可能已落盘,必须带 resultPersistenceStarted;handler 内该标记计数由 4 升为 5,由既有守卫自己报出。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+399
@@ -0,0 +1,399 @@
|
||||
diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx
|
||||
index b1a423324..d8434e0e1 100644
|
||||
--- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx
|
||||
+++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
+ Grid2X2,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
PersonStanding,
|
||||
@@ -22,6 +23,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
+ onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
+ isPerfectPixelProcessing: boolean;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
isPersistingAssetKind: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
@@ -37,6 +40,8 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
+ onPerfectPixel,
|
||||
+ isPerfectPixelProcessing,
|
||||
isSplittingIconSpritesheet,
|
||||
isPersistingAssetKind,
|
||||
onSplitIconSpritesheet,
|
||||
@@ -120,6 +125,47 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
+ <PlatformIconButton
|
||||
+ className="image-canvas-editor__floating-toolbar-text-button"
|
||||
+ // 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
+ // 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
+ label={
|
||||
+ isPersistingAssetKind
|
||||
+ ? '完美像素等待素材类型保存'
|
||||
+ : isPerfectPixelProcessing
|
||||
+ ? '完美像素处理中'
|
||||
+ : '完美像素'
|
||||
+ }
|
||||
+ title={
|
||||
+ isPersistingAssetKind
|
||||
+ ? '完美像素等待素材类型保存'
|
||||
+ : isPerfectPixelProcessing
|
||||
+ ? '完美像素处理中'
|
||||
+ : '自动识别并规整像素网格'
|
||||
+ }
|
||||
+ icon={
|
||||
+ isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
+ <Loader2 className="h-4 w-4 animate-spin" />
|
||||
+ ) : (
|
||||
+ <Grid2X2 className="h-4 w-4" />
|
||||
+ )
|
||||
+ }
|
||||
+ // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
+ // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
+ // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
+ // 与相邻的拆分图集按钮保持同一套门禁。
|
||||
+ disabled={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
+ aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
+ onClick={() => onPerfectPixel(selectedLayer)}
|
||||
+ >
|
||||
+ <span>
|
||||
+ {isPersistingAssetKind
|
||||
+ ? '保存中'
|
||||
+ : isPerfectPixelProcessing
|
||||
+ ? '处理中'
|
||||
+ : '完美像素'}
|
||||
+ </span>
|
||||
+ </PlatformIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
|
||||
index e40b1e752..43b404216 100644
|
||||
--- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
|
||||
+++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
+import { ApiClientError } from '../../services/apiClient';
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
@@ -18,9 +19,11 @@ import {
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
+ loadEditorProject,
|
||||
splitEditorIconSpritesheet,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
+import { isCanvasGenerationDialogLayoutItem } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
@@ -93,6 +96,7 @@ import {
|
||||
buildCropExpandInsetsFromFrame,
|
||||
removeImageBackground,
|
||||
renderCropExpandImage,
|
||||
+ snapImageToPerfectPixels,
|
||||
} from './ImageCanvasRasterEditModel';
|
||||
import {
|
||||
createUiAssetExtractionDraftMark,
|
||||
@@ -628,6 +632,8 @@ type GenerationWorkflowOptions = {
|
||||
project: EditorProjectSnapshot,
|
||||
action?: CanvasHistoryAction,
|
||||
) => void;
|
||||
+ flushProjectPersistence?: () => Promise<void>;
|
||||
+ refreshAssetLibrary?: () => Promise<unknown> | void;
|
||||
onWalletBalanceMayHaveChanged?: () => void;
|
||||
};
|
||||
|
||||
@@ -662,6 +668,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
+ flushProjectPersistence,
|
||||
+ refreshAssetLibrary,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
}: GenerationWorkflowOptions) {
|
||||
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
||||
@@ -677,6 +685,10 @@ export function useImageCanvasGenerationWorkflow({
|
||||
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
|
||||
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
+ const perfectPixelLayerIdsRef = useRef(new Set<string>());
|
||||
+ const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
|
||||
+ () => new Set(),
|
||||
+ );
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
useState(false);
|
||||
@@ -1734,6 +1746,241 @@ export function useImageCanvasGenerationWorkflow({
|
||||
],
|
||||
);
|
||||
|
||||
+ const snapSelectedLayerToPerfectPixels = useCallback(
|
||||
+ async (sourceLayer: CanvasLayer) => {
|
||||
+ const normalizedProjectId = projectId?.trim();
|
||||
+ const isStaticRasterLayer =
|
||||
+ (sourceLayer.mediaType === undefined ||
|
||||
+ sourceLayer.mediaType === 'image') &&
|
||||
+ sourceLayer.assetKind !== 'character-animation';
|
||||
+ if (
|
||||
+ !isStaticRasterLayer ||
|
||||
+ !normalizedProjectId ||
|
||||
+ !applyProjectSnapshot ||
|
||||
+ !flushProjectPersistence ||
|
||||
+ perfectPixelLayerIdsRef.current.has(sourceLayer.id)
|
||||
+ ) {
|
||||
+ if (
|
||||
+ isStaticRasterLayer &&
|
||||
+ (!normalizedProjectId ||
|
||||
+ !applyProjectSnapshot ||
|
||||
+ !flushProjectPersistence)
|
||||
+ ) {
|
||||
+ showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。');
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ perfectPixelLayerIdsRef.current.add(sourceLayer.id);
|
||||
+ setPerfectPixelLayerIds((currentLayerIds) => {
|
||||
+ const nextLayerIds = new Set(currentLayerIds);
|
||||
+ nextLayerIds.add(sourceLayer.id);
|
||||
+ return nextLayerIds;
|
||||
+ });
|
||||
+ closeGenerationTransientState();
|
||||
+ setImageContextMenu(null);
|
||||
+ setMetadataLayer(null);
|
||||
+ setCropExpandPanel(null);
|
||||
+ setQuickEditPanel(null);
|
||||
+ setCharacterAnimationPanel(null);
|
||||
+
|
||||
+ let perfectPixelDialogId: string | undefined;
|
||||
+ let perfectPixelPostAttempted = false;
|
||||
+ try {
|
||||
+ const assetLabel = `${sourceLayer.title} · 完美像素`;
|
||||
+ const placement = openPlacedCanvasGenerationDialog({
|
||||
+ ...createQuickEditGenerationDialogDraft({
|
||||
+ sourceLayer,
|
||||
+ prompt: '完美像素',
|
||||
+ assetLabel,
|
||||
+ status: 'generating',
|
||||
+ frame: {
|
||||
+ width: sourceLayer.width,
|
||||
+ height: sourceLayer.height,
|
||||
+ },
|
||||
+ }),
|
||||
+ composerOpen: false,
|
||||
+ // 中文注释:完美像素是同步 HTTP,服务端不建 durable job(见 snap_editor_image_to_pixel_art
|
||||
+ // 无 enqueue / spawn)。刷新掉本页会话后,没有任何东西会把这个占位推向终态,而
|
||||
+ // 服务端 409 门禁又要求占位必须先落库,所以只能在读取侧收口:置位后由项目首次
|
||||
+ // 加载时的 dropDeadInlineGenerationPlaceholders 清掉。
|
||||
+ requiresLiveSession: true,
|
||||
+ });
|
||||
+ perfectPixelDialogId = placement.dialogId;
|
||||
+ if (!placement.placeholder) {
|
||||
+ throw new Error('无法创建完美像素处理占位');
|
||||
+ }
|
||||
+
|
||||
+ const sourceImageSrc = await resolveEditorGenerationMediaReference(
|
||||
+ sourceLayer,
|
||||
+ 'image',
|
||||
+ normalizedProjectId,
|
||||
+ );
|
||||
+ await flushProjectPersistence();
|
||||
+ const sourceResourceId = sourceLayer.resourceId.trim();
|
||||
+ perfectPixelPostAttempted = true;
|
||||
+ const result = await snapImageToPerfectPixels({
|
||||
+ sourceImageSrc,
|
||||
+ projectId: normalizedProjectId,
|
||||
+ sourceResourceId:
|
||||
+ sourceResourceId &&
|
||||
+ !sourceResourceId.startsWith('local-resource-') &&
|
||||
+ !sourceResourceId.startsWith('generation-dialog:')
|
||||
+ ? sourceResourceId
|
||||
+ : undefined,
|
||||
+ assetKind: sourceLayer.assetKind,
|
||||
+ generationInputs: sourceLayer.generationInputs,
|
||||
+ assetFolderId,
|
||||
+ assetLabel,
|
||||
+ canvasCompletion: {
|
||||
+ dialogId: perfectPixelDialogId,
|
||||
+ title: assetLabel,
|
||||
+ placeholder: placement.placeholder,
|
||||
+ },
|
||||
+ });
|
||||
+ upsertGeneratedAsset?.(result.asset);
|
||||
+ if (!result.project) {
|
||||
+ if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
+ updateCanvasGenerationDialogById(perfectPixelDialogId, () => null);
|
||||
+ showGenerationWarning(
|
||||
+ '完美像素结果已保存到素材库,画布占位已不存在。',
|
||||
+ );
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
+ if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ applyProjectSnapshot(result.project, {
|
||||
+ type: 'perfect-pixel',
|
||||
+ count: 1,
|
||||
+ });
|
||||
+ setActiveTool('select');
|
||||
+ setActiveSidebarPanel('layers');
|
||||
+ } catch (error) {
|
||||
+ // 中文注释:只有真正发出过 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 (outcomeMayBePersisted && perfectPixelDialogId) {
|
||||
+ // 中文注释:契约要求核对「项目 / 素材」两份快照。素材库是独立记录,只 GET
|
||||
+ // 项目却让用户去核对素材库,他看到的仍是旧列表。
|
||||
+ //
|
||||
+ // 但不能因为调了它就在文案里断言「素材库已刷新」:refreshAssetLibrary 在
|
||||
+ // `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时
|
||||
+ // 弹登录框、其余一律吞掉,返回 `Promise<void>` 不带成败信号;它本身还是可选
|
||||
+ // prop。刷新成功时用户白赚一份新列表,失败时文案必须仍然成立,所以下面只给
|
||||
+ // 「请确认」的指令,不给「已刷新」的事实断言。
|
||||
+ const [reconciled] = await Promise.all([
|
||||
+ loadEditorProject(normalizedProjectId).catch(() => null),
|
||||
+ Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined),
|
||||
+ ]);
|
||||
+ if (reconciled) {
|
||||
+ const placeholderSurvived = reconciled.layers.some(
|
||||
+ (item) =>
|
||||
+ isCanvasGenerationDialogLayoutItem(item) &&
|
||||
+ (item as { dialog?: { id?: unknown } }).dialog?.id ===
|
||||
+ perfectPixelDialogId,
|
||||
+ );
|
||||
+ if (!placeholderSurvived) {
|
||||
+ // 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉,
|
||||
+ // 或者用户在请求期间主动删除。必须再查本地:本地占位也没了就是用户删的,
|
||||
+ // 契约要求此时不应用完成快照、不写历史,删除意图胜出。成功路径同一处有
|
||||
+ // 这道检查,对账路径不能漏。
|
||||
+ if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ applyProjectSnapshot(reconciled, {
|
||||
+ type: 'perfect-pixel',
|
||||
+ count: 1,
|
||||
+ });
|
||||
+ setActiveTool('select');
|
||||
+ setActiveSidebarPanel('layers');
|
||||
+ return;
|
||||
+ }
|
||||
+ // 中文注释:占位仍在,画布没收到结果。这里刻意不调 applyProjectSnapshot:
|
||||
+ // 传给本 hook 的是 ImageCanvasEditorView 的 applyGeneratedProjectSnapshot,
|
||||
+ // 它的 action 默认值是 `generate-image`,不传 action 会写一条类型错误且受
|
||||
+ // 撤销保护的历史;而权威快照此刻和本地状态一致(占位都在),套用它只会白白
|
||||
+ // 覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定而不是同步。
|
||||
+ reconciledMessage = '画布未收到完美像素结果。';
|
||||
+ } else {
|
||||
+ reconciledMessage = '权威项目快照读取失败,无法确认完美像素结果。';
|
||||
+ }
|
||||
+ }
|
||||
+ const serverMessage =
|
||||
+ error instanceof Error && error.message.trim()
|
||||
+ ? error.message
|
||||
+ : undefined;
|
||||
+ // 中文注释:保留服务端原文(例如 assetKind 校验失败)便于定位,同时附上对账结论。
|
||||
+ // 持久化非事务,对象与账号素材可能已落库而只有画布回填没完成,所以必须让用户先核对
|
||||
+ // 素材库再决定,而不是直接重试。尾句只下指令、不断言素材库已经刷新——上面那次刷新
|
||||
+ // 可能是 no-op 或被静默吞掉,断言会让用户对着旧列表判定「没有派生图,可以重试」。
|
||||
+ const errorMessage = reconciledMessage
|
||||
+ ? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请确认素材库是否已生成派生图,再决定是否重试。`
|
||||
+ : (serverMessage ?? '完美像素处理失败');
|
||||
+ if (
|
||||
+ perfectPixelDialogId &&
|
||||
+ hasCanvasGenerationDialogById(perfectPixelDialogId)
|
||||
+ ) {
|
||||
+ updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||||
+ ...dialog,
|
||||
+ status: 'failed',
|
||||
+ errorMessage,
|
||||
+ }));
|
||||
+ } else if (!perfectPixelDialogId) {
|
||||
+ showGenerationWarning(errorMessage);
|
||||
+ }
|
||||
+ } finally {
|
||||
+ perfectPixelLayerIdsRef.current.delete(sourceLayer.id);
|
||||
+ setPerfectPixelLayerIds((currentLayerIds) => {
|
||||
+ if (!currentLayerIds.has(sourceLayer.id)) {
|
||||
+ return currentLayerIds;
|
||||
+ }
|
||||
+ const nextLayerIds = new Set(currentLayerIds);
|
||||
+ nextLayerIds.delete(sourceLayer.id);
|
||||
+ return nextLayerIds;
|
||||
+ });
|
||||
+ }
|
||||
+ },
|
||||
+ [
|
||||
+ applyProjectSnapshot,
|
||||
+ assetFolderId,
|
||||
+ closeGenerationTransientState,
|
||||
+ flushProjectPersistence,
|
||||
+ hasCanvasGenerationDialogById,
|
||||
+ openPlacedCanvasGenerationDialog,
|
||||
+ refreshAssetLibrary,
|
||||
+ projectId,
|
||||
+ setActiveSidebarPanel,
|
||||
+ setActiveTool,
|
||||
+ setCharacterAnimationPanel,
|
||||
+ setCropExpandPanel,
|
||||
+ setImageContextMenu,
|
||||
+ setMetadataLayer,
|
||||
+ setQuickEditPanel,
|
||||
+ showGenerationWarning,
|
||||
+ updateCanvasGenerationDialogById,
|
||||
+ upsertGeneratedAsset,
|
||||
+ ],
|
||||
+ );
|
||||
+
|
||||
const splitSelectedIconSpritesheet = useCallback(
|
||||
async (sourceLayer: CanvasLayer) => {
|
||||
if (
|
||||
@@ -2576,6 +2823,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
openCropExpandPanel,
|
||||
startCropExpandFrameResize,
|
||||
removeSelectedLayerBackground,
|
||||
+ snapSelectedLayerToPerfectPixels,
|
||||
+ perfectPixelLayerIds,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
taskListRefreshKey,
|
||||
@@ -2679,6 +2928,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
quickEditPanel,
|
||||
quickEditSourceLayer,
|
||||
removeSelectedLayerBackground,
|
||||
+ snapSelectedLayerToPerfectPixels,
|
||||
+ perfectPixelLayerIds,
|
||||
splitSelectedIconSpritesheet,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
submitCharacterAnimation,
|
||||
@@ -6028,3 +6028,13 @@
|
||||
- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。
|
||||
- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-03 完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时
|
||||
|
||||
- 缺陷:处理预算(30 秒)只覆盖到规整为止,持久化阶段完全无界,仅受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,远超客户端 `snapEditorImageToPixelArt` 的 120 秒。客户端因此会在服务端仍在合法工作时先 abort:对账虽然照常执行(abort 不是 `ApiClientError`,`outcomeMayBePersisted` 为真),但它采样的是一个仍在途的操作——占位还在、`confirm_asset_object` 未跑完所以素材库还空,用户照提示核对什么也看不到,重试就用新 `task_id` 造出孤儿 OSS 对象。
|
||||
- 决策:给持久化整段套独立预算 `EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION = 60` 秒,用第二个 `tokio::time::timeout_at` 包住从 `persist_editor_generated_image_owned` 到 `complete_editor_canvas_generation` 的全部写入。服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内并留 30 秒余量给网络往返与计时精度。
|
||||
- 为什么独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、中途放弃只会留下孤儿对象。取 min 会让「下载越慢、越容易留孤儿」,方向正好反了。
|
||||
- 超时必须带标记:这条超时发生在 PUT 已经发出之后,对象可能已落盘也可能没有,正是 `resultPersistenceStarted` 契约要覆盖的未知结果。不带标记客户端会判成确定失败、直接诱使用户重试。handler 内该标记的钉定计数因此由 4 升为 5,注释同步说明第五处是什么——这个升级由既有守卫自己报出来(`left: 5, right: 4`),不是事后补记。
|
||||
- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。
|
||||
- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -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);
|
||||
/// 中文注释:持久化阶段的独立预算。它与上面的处理预算相加就是服务端最坏合法时长,必须小于
|
||||
/// 客户端 `snapEditorImageToPixelArt` 的 120 秒超时——否则客户端会在服务端仍在合法工作时
|
||||
/// 先 abort,对账采样到仍在途的操作(占位还在、素材库还空),用户照提示核对却什么也看不到,
|
||||
/// 重试就造出孤儿对象。30 + 60 = 90,余 30 秒给网络往返。
|
||||
/// 独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、
|
||||
/// 中途放弃只会留下孤儿对象。
|
||||
const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(60);
|
||||
/// 中文注释:完美像素持久化是非事务的(OSS + asset object → project resource →
|
||||
/// editor asset → canvas completion)。后段失败时前段记录会保留,而 completion 走
|
||||
/// `map_editor_project_error` 会被映射成 403 / 404 / 409 / 400,客户端从状态码分不出
|
||||
@@ -4899,109 +4906,138 @@ pub async fn snap_editor_image_to_pixel_art(
|
||||
})?;
|
||||
|
||||
let task_id = build_prefixed_uuid_id("pixel-art-snap-");
|
||||
// 中文注释:尺寸已经在上一步取完,snapped_image 之后不再使用,所以按值移交给 _owned
|
||||
// 版本。借用版会在内部 `image.bytes.clone()` 复制一整份输出图片;完美像素是同步路径,
|
||||
// 那份复制会和原图在同一时刻共存。
|
||||
let persisted = persist_editor_generated_image_owned(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
task_id.as_str(),
|
||||
snapped_image,
|
||||
"完美像素",
|
||||
None,
|
||||
EDITOR_PIXEL_ART_SNAP_ASSET_KIND,
|
||||
"pixel-art-snaps",
|
||||
"perfect-pixel",
|
||||
"result",
|
||||
"genarrative",
|
||||
)
|
||||
.await?;
|
||||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||||
let generated_asset = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: Some(project_id.clone()),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id),
|
||||
label: resolve_editor_generated_asset_label(payload.asset_label, "完美像素"),
|
||||
image_src: image_src.clone(),
|
||||
object_key: Some(persisted.object_key.clone()),
|
||||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||||
width,
|
||||
height,
|
||||
prompt: "完美像素".to_string(),
|
||||
actual_prompt: None,
|
||||
model: EDITOR_PIXEL_ART_SNAP_MODEL.to_string(),
|
||||
provider: EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string(),
|
||||
task_id: task_id.clone(),
|
||||
group_task_id: None,
|
||||
group_task_expected_asset_count: None,
|
||||
source_resource_id,
|
||||
asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: 0,
|
||||
let response_task_id = task_id.clone();
|
||||
// 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD
|
||||
// 各自独立计时,加上三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,
|
||||
// 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在
|
||||
// 途的操作:占位还在、素材库还空,用户照提示去查什么也看不到,重试就造出孤儿对象。
|
||||
//
|
||||
// 这里独立起算 60 秒,不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,不该因为
|
||||
// 下载慢就被砍预算,中途放弃只会留下孤儿。处理 30 秒 + 持久化 60 秒 = 服务端最坏 90 秒,
|
||||
// 落在客户端 120 秒内并留出 30 秒余量给网络往返。
|
||||
let persistence_started_at = Instant::now();
|
||||
let persistence_deadline = persistence_started_at
|
||||
.checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION)
|
||||
.unwrap_or(persistence_started_at);
|
||||
let (persisted, image_src, resource, asset, completed_project) = tokio::time::timeout_at(
|
||||
tokio::time::Instant::from_std(persistence_deadline),
|
||||
async move {
|
||||
// 中文注释:尺寸已经在上一步取完,snapped_image 之后不再使用,所以按值移交给 _owned
|
||||
// 版本。借用版会在内部 `image.bytes.clone()` 复制一整份输出图片;完美像素是同步路径,
|
||||
// 那份复制会和原图在同一时刻共存。
|
||||
let persisted = persist_editor_generated_image_owned(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
task_id.as_str(),
|
||||
snapped_image,
|
||||
"完美像素",
|
||||
None,
|
||||
EDITOR_PIXEL_ART_SNAP_ASSET_KIND,
|
||||
"pixel-art-snaps",
|
||||
"perfect-pixel",
|
||||
"result",
|
||||
"genarrative",
|
||||
)
|
||||
.await?;
|
||||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||||
let generated_asset = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: Some(project_id.clone()),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id),
|
||||
label: resolve_editor_generated_asset_label(payload.asset_label, "完美像素"),
|
||||
image_src: image_src.clone(),
|
||||
object_key: Some(persisted.object_key.clone()),
|
||||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||||
width,
|
||||
height,
|
||||
prompt: "完美像素".to_string(),
|
||||
actual_prompt: None,
|
||||
model: EDITOR_PIXEL_ART_SNAP_MODEL.to_string(),
|
||||
provider: EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string(),
|
||||
task_id: task_id.clone(),
|
||||
group_task_id: None,
|
||||
group_task_expected_asset_count: None,
|
||||
source_resource_id,
|
||||
asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
error = %error,
|
||||
"editor_pixel_art_snap_asset_persistence_failed_after_object_put"
|
||||
);
|
||||
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
let resource = generated_asset.resource.ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
"editor_pixel_art_snap_project_resource_missing_after_object_put"
|
||||
);
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"完美像素结果未创建项目资源。",
|
||||
)
|
||||
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
let asset = generated_asset.asset.ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
resource_id = %resource.resource_id,
|
||||
"editor_pixel_art_snap_account_asset_missing_after_object_put"
|
||||
);
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"完美像素结果未创建账号素材。",
|
||||
)
|
||||
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
// 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经
|
||||
// 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。
|
||||
let completed_project = complete_editor_canvas_generation(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
Some(project_id.as_str()),
|
||||
Some(&payload.canvas_completion),
|
||||
Some(&resource),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
resource_id = %resource.resource_id,
|
||||
error = %error,
|
||||
"editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence"
|
||||
);
|
||||
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
Ok::<_, AppError>((persisted, image_src, resource, asset, completed_project))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
error = %error,
|
||||
"editor_pixel_art_snap_asset_persistence_failed_after_object_put"
|
||||
);
|
||||
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
let resource = generated_asset.resource.ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
"editor_pixel_art_snap_project_resource_missing_after_object_put"
|
||||
);
|
||||
// 中文注释:超时发生在 PUT 已经发出之后,对象可能已经落盘也可能没有——正是契约要覆盖的
|
||||
// 未知结果,必须带标记,否则客户端会把它当确定失败、直接诱使用户重试再造一份。
|
||||
.map_err(|_| {
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"完美像素结果未创建项目资源。",
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"完美像素结果持久化超出处理预算。",
|
||||
)
|
||||
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
let asset = generated_asset.asset.ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
resource_id = %resource.resource_id,
|
||||
"editor_pixel_art_snap_account_asset_missing_after_object_put"
|
||||
);
|
||||
editor_pixel_art_snap_failure(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"完美像素结果未创建账号素材。",
|
||||
)
|
||||
.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
// 中文注释:占位在处理期间被用户删除时,沿用现有 completion 规则,只保留已经
|
||||
// 登记的项目资源和账号素材,不复活图层,也不把该并发行为误报为像素处理失败。
|
||||
let completed_project = complete_editor_canvas_generation(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
Some(project_id.as_str()),
|
||||
Some(&payload.canvas_completion),
|
||||
Some(&resource),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
provider = EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
task_id = %task_id,
|
||||
object_key = %persisted.object_key,
|
||||
resource_id = %resource.resource_id,
|
||||
error = %error,
|
||||
"editor_pixel_art_snap_canvas_completion_failed_after_asset_persistence"
|
||||
);
|
||||
error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true))
|
||||
})?;
|
||||
})??;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -5012,7 +5048,7 @@ pub async fn snap_editor_image_to_pixel_art(
|
||||
width,
|
||||
height,
|
||||
source_type: "generated",
|
||||
task_id,
|
||||
task_id: response_task_id,
|
||||
elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
provider: EDITOR_PIXEL_ART_SNAP_PROVIDER,
|
||||
resource,
|
||||
@@ -12162,6 +12198,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_art_server_worst_case_fits_inside_the_client_timeout() {
|
||||
// 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端
|
||||
// `snapEditorImageToPixelArt` 配的是 120 秒;服务端最坏合法时长是处理预算加持久化
|
||||
// 预算之和——处理段(准入、归属校验、下载、规整)由 processing_deadline 封顶,
|
||||
// 持久化段独立起算由 persistence_deadline 封顶。两者相加必须真小于客户端超时,
|
||||
// 否则客户端会在服务端仍在合法工作时先 abort:对账采样到仍在途的操作,占位还在、
|
||||
// 素材库还空,用户照提示核对却什么也看不到,重试就造出孤儿 OSS 对象。
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
let server_worst_case =
|
||||
EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION + EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION;
|
||||
|
||||
assert_eq!(server_worst_case, Duration::from_secs(90));
|
||||
assert!(
|
||||
server_worst_case < CLIENT_TIMEOUT,
|
||||
"server worst case {server_worst_case:?} must stay under the client timeout {CLIENT_TIMEOUT:?}"
|
||||
);
|
||||
// 中文注释:余量不能只是「小于」。网络往返、代理缓冲和客户端计时精度都要吃掉一部分,
|
||||
// 贴着上限等于没有余量。
|
||||
assert!(
|
||||
CLIENT_TIMEOUT - server_worst_case >= Duration::from_secs(30),
|
||||
"client timeout should keep at least 30s of headroom over the server worst case"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_pixel_art_snap_contract_preserves_source_metadata() {
|
||||
let request: EditorPixelArtSnapRequest = serde_json::from_value(json!({
|
||||
@@ -12455,9 +12516,18 @@ mod tests {
|
||||
"validate_editor_pixel_art_static_raster",
|
||||
"snap_editor_pixel_art_strict",
|
||||
"Some(processing_deadline)",
|
||||
// 中文注释:持久化整段必须先被第二个 timeout_at 包住再开始写。此前这一段
|
||||
// 完全无界,只受 OSS 客户端每请求 120 秒约束,服务端最坏合法时长超过客户端
|
||||
// 超时,客户端会先 abort 而服务端还在合法工作。顺序断言把「预算在前、写入在后」
|
||||
// 钉死:任何把 persist 挪到 timeout_at 之前的改动都会失败。
|
||||
"EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION",
|
||||
"tokio::time::timeout_at(",
|
||||
"persist_editor_generated_image",
|
||||
"persist_editor_generated_asset",
|
||||
"complete_editor_canvas_generation",
|
||||
// 中文注释:持久化超时同样发生在 PUT 之后,属于未知结果,必须带标记。
|
||||
"完美像素结果持久化超出处理预算。",
|
||||
"EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL",
|
||||
],
|
||||
);
|
||||
assert_function_occurrence_count(
|
||||
@@ -12498,14 +12568,17 @@ mod tests {
|
||||
// 中文注释:第一次 OSS PUT 之后的每条失败路径都必须带 resultPersistenceStarted。
|
||||
// 服务端持久化非事务,completion 失败经 map_editor_project_error 会变成 4xx,
|
||||
// 客户端从状态码分不出失败在持久化前还是后;漏标一处,那条路径上的失败就会被前端
|
||||
// 判成「结果已知」,用户重试再造一份对象、资源和素材。四处分别对应:账号素材持久化
|
||||
// 失败、项目资源缺失、账号素材缺失、画布回填失败。
|
||||
// 判成「结果已知」,用户重试再造一份对象、资源和素材。五处分别对应:账号素材持久化
|
||||
// 失败、项目资源缺失、账号素材缺失、画布回填失败,以及持久化整段超出独立预算——
|
||||
// 最后一处的超时同样发生在 PUT 之后,对象可能已落盘也可能没有,正是契约要覆盖的
|
||||
// 未知结果。(helper 内部另有四处,由 editor_matting_releases_source_buffers_at_oss_boundaries
|
||||
// 单独钉住;调用方拿到的是同一个 AppError,分不出内部走到了哪一步。)
|
||||
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,
|
||||
5,
|
||||
);
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
|
||||
Reference in New Issue
Block a user