From f0ea23978dd5deb7edbbb0edcc1e94acb6cbf025 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 23 Aug 2026 18:28:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=B1=E8=B4=A5=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E7=AB=9E=E6=80=81=E4=B8=8E=E5=AF=B9=E8=B4=A6=E9=98=BB?= =?UTF-8?q?=E6=96=AD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将失败结算的 revision 校验、公开投影和 staging 更新收敛到同一草稿锁内。 对账失败结算后保持安全恢复阻断态,避免误恢复为可编辑状态。 补充过期 revision 不改写账本及前端对账阻断回归测试。 --- .../src-tauri/src/project/asset_canvas.rs | 30 ++- .../src/project/asset_canvas/generation.rs | 179 +++++++++++++----- .../asset-canvas/AssetCanvasSurface.tsx | 10 + .../tests/assetCanvasSurface.test.tsx | 36 ++++ 4 files changed, 197 insertions(+), 58 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 41628c921..a0a13623c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -2221,7 +2221,7 @@ fn stage_asset_canvas_image_with_token_at( }) } -fn rebind_asset_canvas_staged_image_revision_at( +fn rebind_asset_canvas_staged_image_revision_locked( root: &Path, staged_image_token: &str, expected_project_id: &str, @@ -2229,11 +2229,6 @@ fn rebind_asset_canvas_staged_image_revision_at( expected_previous_revision: u64, next_revision: u64, ) -> Result<(), String> { - validate_uuid_v4(staged_image_token, "stagedImageToken")?; - validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; - validate_safe_revision(next_revision, "staging 新草稿 revision")?; - validate_asset_canvas_project_identity(root, expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; @@ -2260,6 +2255,29 @@ fn rebind_asset_canvas_staged_image_revision_at( ) } +fn rebind_asset_canvas_staged_image_revision_at( + root: &Path, + staged_image_token: &str, + expected_project_id: &str, + draft_id: &str, + expected_previous_revision: u64, + next_revision: u64, +) -> Result<(), String> { + validate_uuid_v4(staged_image_token, "stagedImageToken")?; + validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; + validate_safe_revision(next_revision, "staging 新草稿 revision")?; + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + staged_image_token, + expected_project_id, + draft_id, + expected_previous_revision, + next_revision, + ) +} + fn draft_media_relative_path( draft_id: &str, media_ref: &AssetCanvasMediaRef, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index f5745bfcb..0786884c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -1160,13 +1160,12 @@ fn ensure_generation_draft_active( validate_generation_draft_active(ledger, &draft) } -fn upsert_public_generation_record( +fn upsert_public_generation_record_locked( root: &Path, ledger: &AssetCanvasGenerationLedger, ) -> Result<(AssetCanvasGenerationRecord, u64), String> { let phase = public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?; - let _lock = acquire_asset_canvas_draft_lock(root)?; let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; validate_generation_draft_for_public_phase(ledger, &draft)?; @@ -1234,6 +1233,14 @@ fn upsert_public_generation_record( Ok((record, draft.revision)) } +fn upsert_public_generation_record( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result<(AssetCanvasGenerationRecord, u64), String> { + let _lock = acquire_asset_canvas_draft_lock(root)?; + upsert_public_generation_record_locked(root, ledger) +} + fn publish_public_phase( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -2802,6 +2809,101 @@ fn mark_generation_error( publish_public_phase(root, ledger, emit).map(|_| ()) } +fn settle_generation_failure_at_expected_revision( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + expected_draft_revision: u64, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + let (disposition, record, draft) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + if matches!( + ledger.phase, + GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::ArchivePending + | GenerationLedgerPhase::Archived + ) { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "already-terminal".to_string(), + draft, + }); + } + let reconciliation = + reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + let target_phase = if reconciliation { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Failed + }; + let already_settled = ledger.phase == target_phase + && ledger.error_code.as_deref() == Some(error_code) + && draft.generations.iter().any(|record| { + record.generation_id == ledger.generation_id + && record.intent_id == ledger.intent_id + && record.phase == public_phase(&target_phase).expect("failure phase is public") + && record.error_code.as_deref() == Some(error_code) + }); + if already_settled { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: if reconciliation { + "reconciliation-required".to_string() + } else { + "failed".to_string() + }, + draft, + }); + } + validate_generation_draft_active(ledger, &draft)?; + ledger.phase = if reconciliation { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Failed + }; + ledger.error_code = Some(error_code.to_string()); + let previous_staged_revision = ledger.staged_draft_revision; + let (record, draft_revision) = upsert_public_generation_record_locked(root, ledger)?; + ledger.current_draft_revision = Some(draft_revision); + if let Some(previous_revision) = previous_staged_revision { + rebind_asset_canvas_staged_image_revision_locked( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + previous_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + } + write_generation_ledger(root, ledger)?; + draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + ( + if reconciliation { + "reconciliation-required".to_string() + } else { + "failed".to_string() + }, + record, + draft, + ) + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(FinalizeAssetCanvasGenerationFailureResult { disposition, draft }) +} + fn sanitized_generation_error(code: &str) -> String { match code { "configuration-missing" => match editor_api_mode() { @@ -3670,56 +3772,15 @@ pub(crate) async fn finalize_asset_canvas_generation_failure_at( { return Err("生成失败结算对应的账本身份无效".to_string()); } - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.revision != input.expected_draft_revision { - return Err("draft-revision-conflict".to_string()); - } - let terminal_disposition = match ledger.phase { - GenerationLedgerPhase::CandidateReady - | GenerationLedgerPhase::AssetDurableCommitted - | GenerationLedgerPhase::ArchivePending - | GenerationLedgerPhase::Archived => Some("already-terminal".to_string()), - _ => None, - }; - if let Some(disposition) = terminal_disposition { - return Ok(FinalizeAssetCanvasGenerationFailureResult { disposition, draft }); - } - let reconciliation_required = input.reconciliation_required - || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; - let target_phase = if reconciliation_required { - GenerationLedgerPhase::ReconciliationRequired - } else { - GenerationLedgerPhase::Failed - }; - let already_settled = ledger.phase == target_phase - && ledger.error_code.as_deref() == Some(input.error_code.as_str()) - && draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.intent_id == input.intent_id - && record.phase == public_phase(&target_phase).expect("failure phase is public") - && record.error_code.as_deref() == Some(input.error_code.as_str()) - }); - if !already_settled { - let mut emit = |_event: AssetCanvasGenerationProgressEvent| {}; - mark_generation_error( - root, - &mut ledger, - reconciliation_required, - &input.error_code, - &mut emit, - )?; - } - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - return Ok(FinalizeAssetCanvasGenerationFailureResult { - disposition: if reconciliation_required { - "reconciliation-required".to_string() - } else { - "failed".to_string() - }, - draft, - }); + let mut emit = |_event: AssetCanvasGenerationProgressEvent| {}; + return settle_generation_failure_at_expected_revision( + root, + &mut ledger, + input.expected_draft_revision, + input.reconciliation_required, + &input.error_code, + &mut emit, + ); } let _draft_guard = acquire_asset_canvas_draft_lock(root)?; @@ -5151,6 +5212,20 @@ mod tests { .expect_err("stale failure settlement must fail CAS"), "draft-revision-conflict" ); + let draft_after_stale_settlement = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read draft after stale failure settlement") + .expect("draft remains after stale failure settlement"); + assert_eq!(draft_after_stale_settlement, running_draft); + let ledger_after_stale_settlement = + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ledger after stale failure settlement") + .expect("ledger remains after stale failure settlement"); + assert_eq!( + ledger_after_stale_settlement.phase, + GenerationLedgerPhase::Running + ); + assert_eq!(ledger_after_stale_settlement.error_code, None); let settle = FinalizeAssetCanvasGenerationFailureInput { expected_draft_revision: running_draft.revision, diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index 5a729f316..04e0e279e 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -2572,6 +2572,16 @@ export function AssetCanvasSurface({ setNotice(`${message};失败状态保存未完成,请重新打开后对账`); return false; } + if (settled.value.disposition === 'reconciliation-required') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code, + message, + reconciliationRequired: true, + }); + return true; + } setLifecycle({ kind: 'canvas.editing', dirty: false }); return true; }; diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 88f8782ff..68f880299 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -2900,6 +2900,42 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { ), ).toBeTruthy(); }); + it('生成失败需要对账时保持安全恢复阻断态', async () => { + const memory = memoryHost({ + initialDraft: draftFixture(scope), + generationFailure: { + code: 'reconciliation-required', + message: '远端生成结果需要安全对账', + }, + }); + renderSurface(memory.host); + await screen.findByText('画布可编辑'); + + fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' })); + fireEvent.change(screen.getByLabelText('图片提示词'), { + target: { value: '需要安全对账的游戏场景' }, + }); + fireEvent.click(screen.getByRole('button', { name: '继续确认' })); + fireEvent.click(screen.getByRole('button', { name: '确认并生成' })); + + expect( + await screen.findByRole('alert', { name: '原任务恢复未完成' }), + ).not.toBeNull(); + expect( + screen.getAllByText('远端生成结果需要安全对账').length, + ).toBeGreaterThan(0); + expect(memory.getDraft()?.generations[0]?.phase).toBe( + 'reconciliation-required', + ); + const surface = screen.getByRole('region', { name: '素材创作无限画布' }); + const viewport = document.querySelector( + '.asset-canvas-surface__viewport', + ) as HTMLElement; + expect(surface.getAttribute('data-state')).toBe('canvas.failed'); + expect(viewport.hasAttribute('inert')).toBe(true); + expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull(); + expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull(); + }); it('快速编辑失败只记录任务错误,已有图片仍可继续编辑', async () => { const prompt = '登录恢复后继续生成的角色立绘'; const memory = memoryHost({