From 3ea94b27d4042a896d6ebf17a7a081e541fc931e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 9 Sep 2026 20:04:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=20asset-canvas=20Rust=20?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=AD=98=E5=82=A8=E4=B8=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 project/asset_canvas.rs(4784 行,草稿 sidecar、媒体 staging、正式提交事务与 journal) - 删除 project/asset_canvas/generation.rs(9514 行,草稿私有生成账本、参考图上传、服务身份确认、候选落盘与内联 63 个测试) - 删除 project/asset_canvas_tests.rs(2356 行) - project.rs 移除 mod asset_canvas 与 pub(crate) use asset_canvas::* - commands.rs 移除 851-1088 共 18 个 Tauri 命令(含 commit_local_project_asset、commit_local_project_asset_canvas_candidate) - main.rs 从 generate_handler! 移除对应 18 行注册 - 保留 canvas.asset_generate 命令契约(packages/shared 与 shared-contracts 的正式权限合同,Agent Runtime 仍在使用) - 保留 server-rs 的 asset-canvas-references 上传票据白名单契约(后续项,本次不动服务端) --- .../src-tauri/src/commands.rs | 238 - .../src-tauri/src/main.rs | 18 - .../src-tauri/src/project.rs | 2 - .../src-tauri/src/project/asset_canvas.rs | 4784 --------- .../src/project/asset_canvas/generation.rs | 9514 ----------------- .../src/project/asset_canvas_tests.rs | 2356 ---- 6 files changed, 16912 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 351fcf45c..12b3f1dd9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -848,244 +848,6 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } -#[tauri::command] -pub(crate) fn create_local_project_asset_canvas_draft( - input: CreateAssetCanvasDraftInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - create_asset_canvas_draft_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn read_local_project_asset_canvas_draft( - input: ReadAssetCanvasDraftInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - read_asset_canvas_draft_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn discover_local_project_asset_canvas_draft( - input: DiscoverAssetCanvasDraftInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - discover_asset_canvas_draft_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn update_local_project_asset_canvas_draft( - input: UpdateAssetCanvasDraftInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - update_asset_canvas_draft_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers( - input: AcknowledgeAssetCanvasCandidateLayersInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - acknowledge_candidate_layers_at(&root, &input) -} - -#[tauri::command] -pub(crate) async fn import_local_project_asset_canvas_images( - app: tauri::AppHandle, - input: ImportAssetCanvasImagesInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title("导入图片") - .add_filter("图片", &["png", "jpg", "jpeg", "webp"]); - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_files(move |paths| { - let _ = sender.send(paths); - }); - let Some(paths) = receiver - .await - .map_err(|_| "图片文件选择器意外关闭".to_string())? - else { - return Ok(ImportAssetCanvasImagesResult::cancelled()); - }; - if paths.is_empty() { - return Ok(ImportAssetCanvasImagesResult::cancelled()); - } - let paths = paths - .into_iter() - .map(|path| { - path.into_path() - .map_err(|error| format!("读取导入图片路径失败:{error}")) - }) - .collect::, _>>()?; - tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths)) - .await - .map_err(|error| format!("图片导入任务异常结束:{error}"))? -} - -#[tauri::command] -pub(crate) fn store_local_project_asset_canvas_media( - input: StoreAssetCanvasMediaInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - store_asset_canvas_media_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn stage_local_project_asset_canvas_image( - input: StageAssetCanvasImageInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - stage_asset_canvas_image_at(&root, &input) -} - -#[tauri::command] -pub(crate) async fn archive_failed_local_project_asset_canvas_generation( - input: ArchiveAssetCanvasGenerationInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - archive_failed_asset_canvas_generation_at(&root, &input).await -} - -#[tauri::command] -pub(crate) async fn generate_local_project_asset_canvas_image( - app: tauri::AppHandle, - input: GenerateAssetCanvasImageInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - let progress_app = app.clone(); - let execution = generate_asset_canvas_image_at(&root, &input, move |payload| { - let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload); - }) - .await?; - if let Some(event) = execution.event.as_ref() { - publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit( - "game-creator-local-asset-committed", - asset_canvas_committed_public_event(payload), - ) - .map_err(|error| error.to_string()) - }); - } - Ok(execution.result) -} - -#[tauri::command] -pub(crate) async fn finalize_local_project_asset_canvas_generation_failure( - input: FinalizeAssetCanvasGenerationFailureInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - finalize_asset_canvas_generation_failure_at(&root, &input).await -} - -#[tauri::command] -pub(crate) async fn recover_local_project_asset_canvas_generations( - app: tauri::AppHandle, - input: RecoverAssetCanvasGenerationsInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - let progress_app = app.clone(); - let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| { - let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload); - }) - .await?; - for event in &execution.events { - publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit( - "game-creator-local-asset-committed", - asset_canvas_committed_public_event(payload), - ) - .map_err(|error| error.to_string()) - }); - } - Ok(execution.result) -} - -#[tauri::command] -pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity( - input: ConfirmAssetCanvasGenerationServiceIdentityInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - confirm_asset_canvas_generation_service_identity_at(&root, &input).await -} - -#[tauri::command] -pub(crate) fn read_local_project_asset_canvas_media( - input: ReadAssetCanvasMediaInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - read_asset_canvas_media_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn discard_local_project_asset_canvas_draft( - input: DiscardAssetCanvasDraftInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - discard_asset_canvas_draft_at(&root, &input) -} - -#[tauri::command] -pub(crate) fn commit_local_project_asset( - app: tauri::AppHandle, - input: CommitAssetCanvasInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - let execution = commit_asset_canvas_at(&root, &input)?; - if let Some(event) = execution.event.as_ref() { - publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit( - "game-creator-local-asset-committed", - asset_canvas_committed_public_event(payload), - ) - .map_err(|error| error.to_string()) - }); - } - Ok(execution.result) -} - -#[tauri::command] -pub(crate) fn commit_local_project_asset_canvas_candidate( - app: tauri::AppHandle, - input: CommitAssetCanvasCandidateInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - let execution = commit_asset_canvas_candidate_at(&root, &input)?; - if let Some(event) = execution.event.as_ref() { - publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit( - "game-creator-local-asset-committed", - asset_canvas_committed_public_event(payload), - ) - .map_err(|error| error.to_string()) - }); - } - Ok(execution.result) -} - -#[tauri::command] -pub(crate) fn recover_local_project_asset_canvas_transactions( - app: tauri::AppHandle, - input: RecoverAssetCanvasTransactionsInput, -) -> Result { - let root = validated_local_project_directory_path(input.project_path.trim())?; - let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?; - for event in &execution.events { - publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit( - "game-creator-local-asset-committed", - asset_canvas_committed_public_event(payload), - ) - .map_err(|error| error.to_string()) - }); - } - Ok(execution.result) -} - #[tauri::command] pub(crate) async fn control_agent_run( app: tauri::AppHandle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 317b82348..79f9a9862 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2585,24 +2585,6 @@ fn main() { read_local_project_resource_canvas_layout, read_local_project_resource_graph, update_local_project_resource_canvas_layout, - create_local_project_asset_canvas_draft, - read_local_project_asset_canvas_draft, - discover_local_project_asset_canvas_draft, - update_local_project_asset_canvas_draft, - acknowledge_local_project_asset_canvas_candidate_layers, - import_local_project_asset_canvas_images, - store_local_project_asset_canvas_media, - stage_local_project_asset_canvas_image, - finalize_local_project_asset_canvas_generation_failure, - archive_failed_local_project_asset_canvas_generation, - generate_local_project_asset_canvas_image, - recover_local_project_asset_canvas_generations, - confirm_local_project_asset_canvas_generation_service_identity, - read_local_project_asset_canvas_media, - discard_local_project_asset_canvas_draft, - recover_local_project_asset_canvas_transactions, - commit_local_project_asset, - commit_local_project_asset_canvas_candidate, get_local_game_project_revision, get_local_game_manifest, download_agc_update, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 4ffe94674..7a3bc87d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -4,7 +4,6 @@ use similar::TextDiff; use std::io::{Seek, SeekFrom}; mod agent_db; -mod asset_canvas; mod checkpoint; mod conversation; mod export; @@ -18,7 +17,6 @@ mod resource_layout; mod verification; pub(crate) use agent_db::*; -pub(crate) use asset_canvas::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; 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 deleted file mode 100644 index 99c961d92..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ /dev/null @@ -1,4784 +0,0 @@ -use super::*; -use base64::Engine; -use image::GenericImageView; -use std::collections::{BTreeSet, HashSet}; -use std::fs::File; -use unicode_normalization::UnicodeNormalization; -use uuid::Uuid; - -mod generation; -pub(crate) use generation::*; - -const ASSET_CANVAS_SCHEMA_VERSION: &str = "game-creator-asset-canvas-draft.v1"; -const ASSET_CANVAS_COMMIT_SCHEMA_VERSION: &str = "game-creator-local-asset-commit.v1"; -const ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION: &str = "game-creator-local-asset-transaction.v1"; -const ASSET_CANVAS_EVENT_SCHEMA_VERSION: &str = "game-creator-local-asset-committed.v1"; -const ASSET_CANVAS_ROOT: &str = ".agent/workbench/asset-canvas"; -const ASSET_CANVAS_DRAFT_LOCK: &str = ".agent/workbench/asset-canvas/.drafts.lock"; -const ASSET_CANVAS_MAX_DRAFT_BYTES: usize = 2 * 1024 * 1024; -const ASSET_CANVAS_MAX_TRANSACTION_BYTES: usize = 1024 * 1024; -const ASSET_CANVAS_MAX_LEDGER_BYTES: usize = 16 * 1024 * 1024; -const ASSET_CANVAS_MAX_MEDIA_BYTES: usize = 64 * 1024 * 1024; -const ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES: u64 = 512 * 1024 * 1024; -const ASSET_CANVAS_MAX_LAYERS: usize = 4096; -const ASSET_CANVAS_MAX_GENERATIONS: usize = 64; -const ASSET_CANVAS_MAX_REFERENCES: usize = 128; -const ASSET_CANVAS_MAX_IMPORT_BATCH: usize = 64; -const ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES: usize = 4096; -const ASSET_CANVAS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; -const ASSET_CANVAS_MAX_DIMENSION: u32 = 16_384; -const ASSET_CANVAS_MAX_PIXELS: u64 = 268_435_456; -const ASSET_CANVAS_WORLD_SIZE: f64 = 12_000.0; -const ASSET_CANVAS_STAGING_TTL_MILLIS: u64 = 24 * 60 * 60 * 1000; -const ASSET_CANVAS_LOCK_WAIT_ATTEMPTS: usize = 100; -const ASSET_CANVAS_LOCK_WAIT_MILLIS: u64 = 10; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum AssetCanvasIntent { - Create, - Refine, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum AssetCanvasDraftStatus { - Editing, - Generating, - CommitPrepared, - Committed, - Cancelled, - ReconciliationRequired, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "kebab-case")] -pub(crate) enum AssetCanvasMediaRef { - ProjectAsset { - #[serde(rename = "assetId")] - asset_id: String, - }, - DraftMedia { - #[serde(rename = "mediaId")] - media_id: String, - #[serde(rename = "mediaType")] - media_type: String, - sha256: String, - #[serde(rename = "byteLength")] - byte_length: u64, - #[serde(rename = "pixelWidth")] - pixel_width: u32, - #[serde(rename = "pixelHeight")] - pixel_height: u32, - }, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasLayer { - pub(crate) layer_id: String, - pub(crate) resource_id: String, - pub(crate) title: String, - pub(crate) media_ref: AssetCanvasMediaRef, - pub(crate) x: f64, - pub(crate) y: f64, - pub(crate) width: f64, - pub(crate) height: f64, - pub(crate) original_width: f64, - pub(crate) original_height: f64, - pub(crate) z_index: u64, - pub(crate) group_id: Option, - pub(crate) hidden: bool, - pub(crate) locked: bool, - pub(crate) flip_x: bool, - pub(crate) flip_y: bool, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasViewport { - pub(crate) x: f64, - pub(crate) y: f64, - pub(crate) scale: f64, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasState { - pub(crate) viewport: AssetCanvasViewport, - pub(crate) background_color: String, - pub(crate) layers: Vec, - pub(crate) selected_layer_ids: Vec, - pub(crate) primary_selected_layer_id: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum AssetCanvasGenerationStatus { - GenerationAccepted, - GenerationRunning, - RemoteCompleted, - MediaDownloaded, - CandidateReady, - AssetDurableCommitted, - Failed, - ReconciliationRequired, -} - -impl Default for AssetCanvasGenerationStatus { - fn default() -> Self { - Self::GenerationAccepted - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "kebab-case")] -enum LegacyAssetCanvasGenerationStatus { - Accepted, - Polling, - Completed, - Failed, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasGenerationPlaceholder { - pub(crate) x: f64, - pub(crate) y: f64, - pub(crate) width: f64, - pub(crate) height: f64, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasGenerationRecord { - pub(crate) generation_id: String, - #[serde(default)] - pub(crate) intent_id: String, - #[serde(default)] - pub(crate) phase: AssetCanvasGenerationStatus, - pub(crate) reference_resource_ids: Vec, - #[serde(default)] - pub(crate) output_asset_id: Option, - #[serde(default)] - pub(crate) source_layer_id: Option, - #[serde(default)] - pub(crate) placeholder: Option, - pub(crate) error_code: Option, - pub(crate) created_at: u64, - pub(crate) updated_at: u64, - #[serde(default, skip_serializing)] - idempotency_key: Option, - #[serde(default, skip_serializing)] - status: Option, - #[serde(default, skip_serializing)] - prompt: Option, - #[serde(default, skip_serializing)] - operation_id: Option, - #[serde(default, skip_serializing)] - output_media_ids: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasPendingCommit { - pub(crate) commit_id: String, - #[serde(default, skip_serializing)] - idempotency_key: Option, - pub(crate) request_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasLastCommit { - pub(crate) commit_id: String, - #[serde(default, skip_serializing)] - idempotency_key: Option, - pub(crate) asset_id: String, - pub(crate) event_id: String, - pub(crate) committed_project_revision: u64, - #[serde(default)] - pub(crate) source_layer_id: String, - #[serde(default)] - pub(crate) media_sha256: String, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasDraft { - pub(crate) schema_version: String, - pub(crate) draft_id: String, - pub(crate) project_id: String, - pub(crate) intent: AssetCanvasIntent, - pub(crate) source_asset_id: Option, - pub(crate) source_resource_id: Option, - pub(crate) revision: u64, - pub(crate) status: AssetCanvasDraftStatus, - pub(crate) canvas: AssetCanvasState, - pub(crate) generations: Vec, - pub(crate) pending_commit: Option, - pub(crate) last_commit: Option, - pub(crate) created_at: u64, - pub(crate) updated_at: u64, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CreateAssetCanvasDraftInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) intent: AssetCanvasIntent, - pub(crate) source_asset_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum CreateAssetCanvasDraftStatus { - Created, - Existing, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct CreateAssetCanvasDraftResult { - pub(crate) status: CreateAssetCanvasDraftStatus, - pub(crate) draft: AssetCanvasDraft, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ReadAssetCanvasDraftInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum ReadAssetCanvasDraftStatus { - Found, - NotFound, - ProjectIdentityConflict, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ReadAssetCanvasDraftResult { - pub(crate) status: ReadAssetCanvasDraftStatus, - pub(crate) draft: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct DiscoverAssetCanvasDraftInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) intent: AssetCanvasIntent, - pub(crate) source_asset_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum DiscoverAssetCanvasDraftStatus { - Found, - NotFound, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DiscoverAssetCanvasDraftResult { - pub(crate) status: DiscoverAssetCanvasDraftStatus, - pub(crate) draft: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct UpdateAssetCanvasDraftInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) expected_draft_revision: u64, - pub(crate) status: AssetCanvasDraftStatus, - pub(crate) canvas: AssetCanvasState, - pub(crate) generations: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum UpdateAssetCanvasDraftStatus { - Updated, - Conflict, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UpdateAssetCanvasDraftResult { - pub(crate) status: UpdateAssetCanvasDraftStatus, - pub(crate) draft: AssetCanvasDraft, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct StoreAssetCanvasMediaInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) expected_draft_revision: u64, - pub(crate) media_type: String, - pub(crate) bytes: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct StoreAssetCanvasMediaResult { - pub(crate) status: String, - pub(crate) draft_id: String, - pub(crate) draft_revision: u64, - pub(crate) media_ref: Option, - pub(crate) draft: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ImportAssetCanvasImagesInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) expected_draft_revision: u64, - pub(crate) viewport_width: f64, - pub(crate) viewport_height: f64, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum ImportAssetCanvasImagesStatus { - Imported, - Cancelled, - Conflict, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ImportAssetCanvasImagesResult { - pub(crate) status: ImportAssetCanvasImagesStatus, - pub(crate) draft: Option, - pub(crate) imported_layer_ids: Vec, -} - -impl ImportAssetCanvasImagesResult { - pub(crate) fn cancelled() -> Self { - Self { - status: ImportAssetCanvasImagesStatus::Cancelled, - draft: None, - imported_layer_ids: Vec::new(), - } - } -} - -struct ValidatedAssetCanvasImport { - title: String, - media_type: String, - bytes: Vec, - width: u32, - height: u32, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct StageAssetCanvasImageInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) expected_draft_revision: u64, - pub(crate) media_type: String, - pub(crate) bytes: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AssetCanvasStagedImage { - schema_version: String, - project_id: String, - draft_id: String, - draft_revision: u64, - staged_image_token: String, - media_type: String, - sha256: String, - byte_length: u64, - pixel_width: u32, - pixel_height: u32, - expires_at: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct StageAssetCanvasImageResult { - pub(crate) status: String, - pub(crate) staged_image_token: Option, - pub(crate) draft_id: String, - pub(crate) draft_revision: u64, - pub(crate) media_type: Option, - pub(crate) sha256: Option, - pub(crate) byte_length: Option, - pub(crate) pixel_width: Option, - pub(crate) pixel_height: Option, - pub(crate) expires_at: Option, - pub(crate) draft: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ReadAssetCanvasMediaInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) media_ref: AssetCanvasMediaRef, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ReadAssetCanvasMediaResult { - pub(crate) media_type: String, - pub(crate) bytes: Vec, -} - -#[derive(Debug)] -struct AssetCanvasDraftLock { - _file: File, -} - -fn asset_canvas_now() -> u64 { - unix_millis().min(u128::from(ASSET_CANVAS_MAX_SAFE_INTEGER)) as u64 -} - -fn asset_canvas_sha256(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -fn new_asset_canvas_token() -> Result { - let mut bytes = [0_u8; 32]; - getrandom::fill(&mut bytes).map_err(|_| "生成素材画布安全随机身份失败".to_string())?; - Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) -} - -fn validate_uuid_v4(value: &str, label: &str) -> Result<(), String> { - let value = value.trim(); - let parsed = Uuid::parse_str(value).map_err(|_| format!("{label} 必须是小写 UUID v4"))?; - if parsed.get_version_num() != 4 || parsed.hyphenated().to_string() != value { - return Err(format!("{label} 必须是小写 UUID v4")); - } - Ok(()) -} - -fn validate_safe_revision(value: u64, label: &str) -> Result<(), String> { - if value > ASSET_CANVAS_MAX_SAFE_INTEGER { - return Err(format!("{label} 超过 JavaScript 安全整数上限")); - } - Ok(()) -} - -fn asset_canvas_draft_relative_path(draft_id: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/drafts/{draft_id}.json") -} - -fn asset_canvas_recovery_relative_path(draft_id: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/drafts/.recovery/{draft_id}.json") -} - -fn asset_canvas_recovery_sha_relative_path(draft_id: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/drafts/.recovery/{draft_id}.sha256") -} - -fn current_asset_canvas_manifest(root: &Path) -> Result { - validate_project_root(root)?; - let path = resolve_local_project_path(root, ".agent/manifest.json")?; - let manifest = read_manifest(&path)?; - if manifest.project_id.trim().is_empty() { - return Err("项目 manifest 缺少 projectId".to_string()); - } - Ok(manifest) -} - -fn validate_asset_canvas_project_identity( - root: &Path, - expected_project_id: &str, -) -> Result { - let expected_project_id = expected_project_id.trim(); - if expected_project_id.is_empty() { - return Err("expectedProjectId 不能为空".to_string()); - } - let manifest = current_asset_canvas_manifest(root)?; - if manifest.project_id != expected_project_id { - return Err("expectedProjectId 与当前项目不匹配".to_string()); - } - Ok(manifest) -} - -fn validate_plain_component(value: &str, label: &str, max_chars: usize) -> Result<(), String> { - let value = value.trim(); - if value.is_empty() - || value.chars().count() > max_chars - || value.chars().any(char::is_control) - || value.contains('/') - || value.contains('\\') - || matches!(value, "." | "..") - { - return Err(format!("{label} 无效")); - } - Ok(()) -} - -fn media_extension(media_type: &str) -> Result<&'static str, String> { - match media_type.trim().to_ascii_lowercase().as_str() { - "image/png" => Ok("png"), - "image/jpeg" => Ok("jpg"), - "image/webp" => Ok("webp"), - _ => Err("只支持 PNG、JPEG 和 WebP 图片".to_string()), - } -} - -fn validate_image_bytes(media_type: &str, bytes: &[u8]) -> Result<(String, u32, u32), String> { - if bytes.is_empty() || bytes.len() > ASSET_CANVAS_MAX_MEDIA_BYTES { - return Err(format!( - "图片大小必须在 1..={ASSET_CANVAS_MAX_MEDIA_BYTES} 字节" - )); - } - let normalized = match media_extension(media_type)? { - "png" => "image/png", - "jpg" => "image/jpeg", - "webp" => "image/webp", - _ => unreachable!(), - }; - let signature_matches = match normalized { - "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), - "image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), - "image/webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP", - _ => false, - }; - if !signature_matches { - return Err("图片文件签名与媒体类型不匹配".to_string()); - } - let decoded = image::load_from_memory(bytes).map_err(|_| "图片无法完整解码".to_string())?; - let (width, height) = decoded.dimensions(); - if width == 0 - || height == 0 - || width > ASSET_CANVAS_MAX_DIMENSION - || height > ASSET_CANVAS_MAX_DIMENSION - || u64::from(width) * u64::from(height) > ASSET_CANVAS_MAX_PIXELS - { - return Err("图片尺寸超过素材画布上限".to_string()); - } - Ok((normalized.to_string(), width, height)) -} - -fn validate_asset_canvas_state(canvas: &AssetCanvasState) -> Result<(), String> { - if canvas.layers.len() > ASSET_CANVAS_MAX_LAYERS { - return Err(format!("素材画布最多支持 {ASSET_CANVAS_MAX_LAYERS} 个图层")); - } - if !canvas.viewport.x.is_finite() - || !canvas.viewport.y.is_finite() - || !canvas.viewport.scale.is_finite() - || !(0.025..=3.2).contains(&canvas.viewport.scale) - { - return Err("素材画布 viewport 无效".to_string()); - } - let mut layer_ids = HashSet::with_capacity(canvas.layers.len()); - let mut resource_ids = HashSet::with_capacity(canvas.layers.len()); - let mut z_indexes = HashSet::with_capacity(canvas.layers.len()); - for layer in &canvas.layers { - validate_plain_component(&layer.layer_id, "layerId", 512)?; - validate_plain_component(&layer.resource_id, "resourceId", 512)?; - if !layer_ids.insert(layer.layer_id.as_str()) - || !resource_ids.insert(layer.resource_id.as_str()) - || !z_indexes.insert(layer.z_index) - { - return Err("素材画布图层身份或层序重复".to_string()); - } - validate_safe_revision(layer.z_index, "zIndex")?; - if layer.title.chars().count() > 200 - || layer.title.chars().any(char::is_control) - || !layer.x.is_finite() - || !layer.y.is_finite() - || !layer.width.is_finite() - || !layer.height.is_finite() - || !layer.original_width.is_finite() - || !layer.original_height.is_finite() - || layer.width <= 0.0 - || layer.height <= 0.0 - || layer.original_width <= 0.0 - || layer.original_height <= 0.0 - { - return Err("素材画布图层字段无效".to_string()); - } - } - let selected = canvas - .selected_layer_ids - .iter() - .map(String::as_str) - .collect::>(); - if selected.len() != canvas.selected_layer_ids.len() - || selected.iter().any(|id| { - canvas - .layers - .iter() - .find(|layer| layer.layer_id == **id) - .is_none_or(|layer| layer.hidden) - }) - || canvas - .primary_selected_layer_id - .as_deref() - .is_some_and(|id| !selected.contains(id)) - { - return Err("素材画布选择引用无效".to_string()); - } - Ok(()) -} - -fn validate_asset_canvas_generation_placeholder( - placeholder: &AssetCanvasGenerationPlaceholder, -) -> Result<(), String> { - if !placeholder.x.is_finite() - || !placeholder.y.is_finite() - || !placeholder.width.is_finite() - || !placeholder.height.is_finite() - || placeholder.x < 0.0 - || placeholder.y < 0.0 - || placeholder.width <= 0.0 - || placeholder.height <= 0.0 - || placeholder.x + placeholder.width > ASSET_CANVAS_WORLD_SIZE - || placeholder.y + placeholder.height > ASSET_CANVAS_WORLD_SIZE - { - return Err("素材画布生成占位超出安全画布边界".to_string()); - } - Ok(()) -} - -fn validate_asset_canvas_sha256(value: &str, label: &str) -> Result<(), String> { - if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(format!("{label} 必须是 SHA-256")); - } - Ok(()) -} - -fn validate_generation_records(records: &[AssetCanvasGenerationRecord]) -> Result<(), String> { - if records.len() > ASSET_CANVAS_MAX_GENERATIONS { - return Err(format!( - "素材画布最多保存 {ASSET_CANVAS_MAX_GENERATIONS} 条生成记录" - )); - } - let mut ids = HashSet::with_capacity(records.len()); - let mut intent_ids = HashSet::with_capacity(records.len()); - for record in records { - validate_uuid_v4(&record.generation_id, "generationId")?; - validate_uuid_v4(&record.intent_id, "intentId")?; - if !ids.insert(record.generation_id.as_str()) - || !intent_ids.insert(record.intent_id.as_str()) - || record.reference_resource_ids.len() > ASSET_CANVAS_MAX_REFERENCES - || record - .output_asset_id - .as_deref() - .is_some_and(|value| validate_plain_component(value, "outputAssetId", 512).is_err()) - { - return Err("素材画布生成记录无效".to_string()); - } - if normalize_asset_canvas_references(&record.reference_resource_ids)? - != record.reference_resource_ids - { - return Err("素材画布生成引用必须是规范化且去重后的稳定身份".to_string()); - } - if let Some(source_layer_id) = record.source_layer_id.as_deref() { - validate_plain_component(source_layer_id, "generation.sourceLayerId", 512)?; - } - if let Some(placeholder) = record.placeholder.as_ref() { - validate_asset_canvas_generation_placeholder(placeholder)?; - } - validate_safe_revision(record.created_at, "generation.createdAt")?; - validate_safe_revision(record.updated_at, "generation.updatedAt")?; - if record.updated_at < record.created_at { - return Err("素材画布生成记录时间顺序无效".to_string()); - } - } - Ok(()) -} - -fn validate_asset_canvas_draft( - draft: &AssetCanvasDraft, - expected_project_id: &str, - expected_draft_id: &str, -) -> Result<(), String> { - if draft.schema_version != ASSET_CANVAS_SCHEMA_VERSION - || draft.project_id != expected_project_id - || draft.draft_id != expected_draft_id - { - return Err("素材画布草稿 schema 或身份不匹配".to_string()); - } - validate_uuid_v4(&draft.draft_id, "draftId")?; - validate_safe_revision(draft.revision, "草稿 revision")?; - match draft.intent { - AssetCanvasIntent::Create - if draft.source_asset_id.is_some() || draft.source_resource_id.is_some() => - { - return Err("create 草稿不能包含源资源".to_string()); - } - AssetCanvasIntent::Refine - if draft.source_asset_id.is_none() || draft.source_resource_id.is_none() => - { - return Err("refine 草稿缺少源资源".to_string()); - } - _ => {} - } - validate_asset_canvas_state(&draft.canvas)?; - validate_generation_records(&draft.generations)?; - if let Some(last_commit) = draft.last_commit.as_ref() { - validate_safe_revision( - last_commit.committed_project_revision, - "lastCommit.committedProjectRevision", - )?; - if !last_commit.source_layer_id.is_empty() { - validate_plain_component( - &last_commit.source_layer_id, - "lastCommit.sourceLayerId", - 512, - )?; - } - if !last_commit.media_sha256.is_empty() { - validate_asset_canvas_sha256(&last_commit.media_sha256, "lastCommit.mediaSha256")?; - } - } - Ok(()) -} - -fn read_asset_canvas_bytes( - root: &Path, - relative_path: &str, - label: &str, - max_bytes: usize, -) -> Result>, String> { - let path = resolve_local_project_path(root, relative_path)?; - match fs::symlink_metadata(&path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(format!("{label} 必须是普通文件")); - } - Ok(metadata) if metadata.len() > max_bytes as u64 => { - return Err(format!("{label} 超过 {max_bytes} 字节上限")); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!( - "读取 {label} 元数据失败:{}: {error}", - path.display() - )) - } - } - let (file, metadata) = open_project_snapshot_regular_file(&path, label)?; - if metadata.len() > max_bytes as u64 { - return Err(format!("{label} 超过 {max_bytes} 字节上限")); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take((max_bytes + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; - if bytes.len() > max_bytes { - return Err(format!("{label} 超过 {max_bytes} 字节上限")); - } - Ok(Some(bytes)) -} - -fn read_asset_canvas_draft_candidate( - root: &Path, - relative_path: &str, - label: &str, - project_id: &str, - draft_id: &str, -) -> Result)>, String> { - let Some(bytes) = - read_asset_canvas_bytes(root, relative_path, label, ASSET_CANVAS_MAX_DRAFT_BYTES)? - else { - return Ok(None); - }; - let mut draft = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 {label} 失败:{error}"))?; - normalize_legacy_generation_records(&mut draft.generations); - if let Some(pending) = draft.pending_commit.as_mut() { - pending.idempotency_key = None; - } - if let Some(last) = draft.last_commit.as_mut() { - last.idempotency_key = None; - } - validate_asset_canvas_draft(&draft, project_id, draft_id)?; - let manifest = current_asset_canvas_manifest(root)?; - if manifest.project_id != project_id { - return Err("素材画布草稿项目身份在读取期间发生变化".to_string()); - } - validate_asset_canvas_draft_media(root, &manifest, &draft)?; - Ok(Some((draft, bytes))) -} - -fn normalize_legacy_generation_records(records: &mut [AssetCanvasGenerationRecord]) { - for record in records { - if record.intent_id.is_empty() { - record.intent_id = record.generation_id.clone(); - } - let had_legacy_status = record.status.is_some(); - if let Some(status) = record.status.take() { - record.phase = match status { - LegacyAssetCanvasGenerationStatus::Accepted => { - AssetCanvasGenerationStatus::GenerationAccepted - } - LegacyAssetCanvasGenerationStatus::Polling => { - AssetCanvasGenerationStatus::GenerationRunning - } - LegacyAssetCanvasGenerationStatus::Completed => { - AssetCanvasGenerationStatus::AssetDurableCommitted - } - LegacyAssetCanvasGenerationStatus::Failed => AssetCanvasGenerationStatus::Failed, - }; - } - if had_legacy_status && record.output_asset_id.is_none() { - record.output_asset_id = record.output_media_ids.first().cloned(); - } - record.reference_resource_ids = - normalize_asset_canvas_references(&record.reference_resource_ids) - .unwrap_or_else(|_| record.reference_resource_ids.clone()); - } -} - -fn validate_asset_canvas_draft_media( - root: &Path, - manifest: &GameCreationAppManifest, - draft: &AssetCanvasDraft, -) -> Result<(), String> { - for layer in &draft.canvas.layers { - match &layer.media_ref { - AssetCanvasMediaRef::ProjectAsset { asset_id } => { - project_asset_image(root, manifest, asset_id)?; - } - media_ref @ AssetCanvasMediaRef::DraftMedia { - byte_length, - pixel_width, - pixel_height, - .. - } => { - let (relative, media_type, expected_sha256) = - draft_media_relative_path(&draft.draft_id, media_ref)?; - let path = resolve_local_project_path(root, &relative)?; - let (bytes, width, height) = - open_and_validate_image_file(&path, &media_type, expected_sha256.as_deref())?; - if bytes.len() as u64 != *byte_length - || width != *pixel_width - || height != *pixel_height - { - return Err("素材画布草稿媒体元数据不匹配".to_string()); - } - } - } - } - Ok(()) -} - -fn write_asset_canvas_raw_sidecar( - root: &Path, - relative_path: &str, - label: &str, - bytes: &[u8], - max_bytes: usize, -) -> Result<(), String> { - if bytes.len() > max_bytes { - return Err(format!("{label} 超过 {max_bytes} 字节上限")); - } - let mut path = resolve_local_project_path(root, relative_path)?; - let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; - path = resolve_local_project_path(root, relative_path)?; - if fs::symlink_metadata(&path).is_ok() { - open_project_snapshot_regular_file(&path, label)?; - } - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("sidecar"); - let temp_path = path.with_file_name(format!( - ".{file_name}.tmp.{}.{}", - std::process::id(), - asset_canvas_now() - )); - let backup_path = path.with_file_name(format!(".{file_name}.previous")); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW).mode(0o600); - } - let mut file = options - .open(&temp_path) - .map_err(|error| format!("创建 {label} 临时文件失败:{error}"))?; - file.write_all(bytes) - .and_then(|_| file.sync_data()) - .map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!("写入 {label} 临时文件失败:{error}") - })?; - drop(file); - match fs::rename(&temp_path, &path) { - Ok(()) => {} - Err(first_error) => { - if let Ok(metadata) = fs::symlink_metadata(&backup_path) { - if metadata.file_type().is_symlink() || !metadata.is_file() { - let _ = fs::remove_file(&temp_path); - return Err(format!("{label} 恢复副本必须是普通文件")); - } - fs::remove_file(&backup_path) - .map_err(|error| format!("清理 {label} 恢复副本失败:{error}"))?; - } - fs::rename(&path, &backup_path) - .map_err(|error| format!("准备替换 {label} 失败:{first_error};{error}"))?; - if let Err(error) = fs::rename(&temp_path, &path) { - let _ = fs::rename(&backup_path, &path); - let _ = fs::remove_file(&temp_path); - return Err(format!("替换 {label} 失败:{error}")); - } - fs::remove_file(&backup_path) - .map_err(|error| format!("清理 {label} 恢复副本失败:{error}"))?; - } - } - #[cfg(unix)] - File::open(path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; - Ok(()) -} - -fn read_asset_canvas_draft_locked( - root: &Path, - project_id: &str, - draft_id: &str, -) -> Result, String> { - let relative = asset_canvas_draft_relative_path(draft_id); - match read_asset_canvas_draft_candidate(root, &relative, "素材画布草稿", project_id, draft_id) - { - Ok(Some((draft, _))) => return Ok(Some(draft)), - Ok(None) => { - if read_asset_canvas_bytes( - root, - &asset_canvas_recovery_relative_path(draft_id), - "素材画布草稿恢复副本", - ASSET_CANVAS_MAX_DRAFT_BYTES, - )? - .is_none() - { - return Ok(None); - } - } - Err(_) => {} - } - let recovery_relative = asset_canvas_recovery_relative_path(draft_id); - let recovery_sha_relative = asset_canvas_recovery_sha_relative_path(draft_id); - let Some((recovered, recovery_bytes)) = read_asset_canvas_draft_candidate( - root, - &recovery_relative, - "素材画布草稿恢复副本", - project_id, - draft_id, - ) - .map_err(|error| format!("reconciliation-required: {error}"))? - else { - return Err("reconciliation-required: 素材画布草稿损坏且恢复副本不存在".to_string()); - }; - let sha_bytes = - read_asset_canvas_bytes(root, &recovery_sha_relative, "素材画布草稿恢复摘要", 128) - .map_err(|error| format!("reconciliation-required: {error}"))? - .ok_or_else(|| "reconciliation-required: 素材画布草稿恢复摘要不存在".to_string())?; - let expected_sha = std::str::from_utf8(&sha_bytes) - .map_err(|_| "reconciliation-required: 素材画布草稿恢复摘要不是 UTF-8".to_string())? - .trim(); - if expected_sha.len() != 64 - || !expected_sha - .bytes() - .all(|value| value.is_ascii_digit() || (b'a'..=b'f').contains(&value)) - || asset_canvas_sha256(&recovery_bytes) != expected_sha - { - return Err("reconciliation-required: 素材画布草稿恢复摘要不匹配".to_string()); - } - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &relative, - "素材画布草稿", - &recovered, - ASSET_CANVAS_MAX_DRAFT_BYTES, - )?; - Ok(Some(recovered)) -} - -fn write_asset_canvas_draft_locked(root: &Path, draft: &AssetCanvasDraft) -> Result<(), String> { - validate_asset_canvas_draft(draft, &draft.project_id, &draft.draft_id)?; - let manifest = current_asset_canvas_manifest(root)?; - validate_asset_canvas_draft_media(root, &manifest, draft)?; - let relative = asset_canvas_draft_relative_path(&draft.draft_id); - if let Some((current, _)) = read_asset_canvas_draft_candidate( - root, - &relative, - "素材画布草稿", - &draft.project_id, - &draft.draft_id, - )? { - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &asset_canvas_recovery_relative_path(&draft.draft_id), - "素材画布草稿恢复副本", - ¤t, - ASSET_CANVAS_MAX_DRAFT_BYTES, - )?; - let recovery_sha = format!( - "{}\n", - asset_canvas_sha256(&asset_canvas_json_bytes(¤t)?) - ); - write_asset_canvas_raw_sidecar( - root, - &asset_canvas_recovery_sha_relative_path(&draft.draft_id), - "素材画布草稿恢复摘要", - recovery_sha.as_bytes(), - 128, - )?; - } - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &relative, - "素材画布草稿", - draft, - ASSET_CANVAS_MAX_DRAFT_BYTES, - )?; - let installed = read_asset_canvas_draft_locked(root, &draft.project_id, &draft.draft_id)? - .ok_or_else(|| "素材画布草稿安装后缺失".to_string())?; - if installed != *draft { - return Err("素材画布草稿安装后回读不一致".to_string()); - } - Ok(()) -} - -#[cfg(unix)] -fn try_acquire_asset_canvas_draft_lock( - root: &Path, -) -> Result, String> { - use std::ffi::CString; - use std::os::fd::{AsRawFd, FromRawFd}; - use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; - - validate_project_root(root)?; - let relative = normalize_relative_path(ASSET_CANVAS_DRAFT_LOCK)?; - let mut parts = relative.split('/').collect::>(); - let file_name = parts - .pop() - .ok_or_else(|| "素材画布锁路径无效".to_string())?; - let mut directory = fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) - .open(root) - .map_err(|_| "安全打开项目目录失败".to_string())?; - for part in parts { - let component = CString::new(part).map_err(|_| "素材画布锁目录无效".to_string())?; - let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) }; - if created != 0 - && std::io::Error::last_os_error().kind() != std::io::ErrorKind::AlreadyExists - { - return Err("创建素材画布锁目录失败".to_string()); - } - let fd = unsafe { - libc::openat( - directory.as_raw_fd(), - component.as_ptr(), - libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, - ) - }; - if fd < 0 { - return Err("素材画布锁目录必须是普通目录".to_string()); - } - directory = unsafe { File::from_raw_fd(fd) }; - } - let file_name = CString::new(file_name).map_err(|_| "素材画布锁文件名无效".to_string())?; - let fd = unsafe { - libc::openat( - directory.as_raw_fd(), - file_name.as_ptr(), - libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW, - 0o600, - ) - }; - if fd < 0 { - return Err("安全打开素材画布锁失败".to_string()); - } - let file = unsafe { File::from_raw_fd(fd) }; - let metadata = file - .metadata() - .map_err(|_| "读取素材画布锁元数据失败".to_string())?; - let uid = unsafe { libc::geteuid() }; - if !metadata.is_file() || metadata.uid() != uid || metadata.nlink() != 1 { - return Err("素材画布锁必须是当前用户持有的无硬链接普通文件".to_string()); - } - file.set_permissions(fs::Permissions::from_mode(0o600)) - .map_err(|_| "收紧素材画布锁权限失败".to_string())?; - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { - Ok(Some(AssetCanvasDraftLock { _file: file })) - } else if std::io::Error::last_os_error().kind() == std::io::ErrorKind::WouldBlock { - Ok(None) - } else { - Err("获取素材画布系统文件锁失败".to_string()) - } -} - -#[cfg(windows)] -fn try_acquire_asset_canvas_draft_lock( - root: &Path, -) -> Result, String> { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - validate_project_root(root)?; - let path = resolve_local_project_path(root, ASSET_CANVAS_DRAFT_LOCK)?; - let parent = path - .parent() - .ok_or_else(|| "素材画布锁缺少父目录".to_string())?; - fs::create_dir_all(parent).map_err(|_| "创建素材画布锁目录失败".to_string())?; - let open_lock = |create_new| { - let mut options = fs::OpenOptions::new(); - options - .read(true) - .write(true) - .share_mode(0) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - if create_new { - options.create_new(true); - } - options.open(&path) - }; - let (file, created) = match open_lock(true) { - Ok(file) => (file, true), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => match open_lock(false) { - Ok(file) => (file, false), - Err(error) if windows_file_lock_is_contended(&error) => { - return Ok(None); - } - Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), - }, - Err(error) if windows_file_lock_is_contended(&error) => return Ok(None), - Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), - }; - validate_windows_regular_file_handle(&file, "素材画布锁")?; - if created { - crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; - } else { - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; - } - Ok(Some(AssetCanvasDraftLock { _file: file })) -} - -#[cfg(windows)] -fn windows_file_lock_is_contended(error: &std::io::Error) -> bool { - const ERROR_SHARING_VIOLATION: i32 = 32; - const ERROR_LOCK_VIOLATION: i32 = 33; - matches!( - error.kind(), - std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock - ) || matches!( - error.raw_os_error(), - Some(ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) - ) -} - -#[cfg(not(any(unix, windows)))] -fn try_acquire_asset_canvas_draft_lock( - _root: &Path, -) -> Result, String> { - Err("当前平台不支持素材画布系统文件锁".to_string()) -} - -fn acquire_asset_canvas_draft_lock(root: &Path) -> Result { - for attempt in 0..ASSET_CANVAS_LOCK_WAIT_ATTEMPTS { - if let Some(lock) = try_acquire_asset_canvas_draft_lock(root)? { - return Ok(lock); - } - if attempt + 1 < ASSET_CANVAS_LOCK_WAIT_ATTEMPTS { - std::thread::sleep(Duration::from_millis(ASSET_CANVAS_LOCK_WAIT_MILLIS)); - } - } - Err("素材画布正在被其他窗口保存,请稍后重试".to_string()) -} - -fn open_and_validate_image_file( - path: &Path, - expected_media_type: &str, - expected_sha256: Option<&str>, -) -> Result<(Vec, u32, u32), String> { - let (mut file, metadata) = open_project_snapshot_regular_file(path, "素材图片")?; - if metadata.len() == 0 || metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 { - return Err("素材图片大小超限".to_string()); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take((ASSET_CANVAS_MAX_MEDIA_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|_| "读取素材图片失败".to_string())?; - if bytes.len() > ASSET_CANVAS_MAX_MEDIA_BYTES { - return Err("素材图片大小超限".to_string()); - } - let (_, width, height) = validate_image_bytes(expected_media_type, &bytes)?; - if expected_sha256.is_some_and(|expected| asset_canvas_sha256(&bytes) != expected) { - return Err("素材图片摘要不匹配".to_string()); - } - Ok((bytes, width, height)) -} - -fn project_asset_image<'a>( - root: &Path, - manifest: &'a GameCreationAppManifest, - asset_id: &str, -) -> Result<(&'a GameCreationAppAssetManifestEntry, Vec, u32, u32), String> { - let mut matches = manifest.assets.iter().filter(|asset| asset.id == asset_id); - let asset = matches - .next() - .ok_or_else(|| "精修源资源不存在".to_string())?; - if matches.next().is_some() - || !matches!( - asset.media_type.as_str(), - "image/png" | "image/jpeg" | "image/webp" - ) - { - return Err("精修源资源必须是唯一登记的 PNG、JPEG 或 WebP 图片".to_string()); - } - let path = resolve_local_project_path(root, &asset.local_path)?; - let (bytes, width, height) = open_and_validate_image_file(&path, &asset.media_type, None)?; - Ok((asset, bytes, width, height)) -} - -fn asset_canvas_source_resource_id(asset: &GameCreationAppAssetManifestEntry) -> String { - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("local-asset:{}", asset.id)) -} - -fn default_asset_canvas_state() -> AssetCanvasState { - AssetCanvasState { - viewport: AssetCanvasViewport { - x: 0.0, - y: 0.0, - scale: 0.5, - }, - background_color: "#f4f4f5".to_string(), - layers: Vec::new(), - selected_layer_ids: Vec::new(), - primary_selected_layer_id: None, - } -} - -pub(crate) fn create_asset_canvas_draft_at( - root: &Path, - input: &CreateAssetCanvasDraftInput, -) -> Result { - validate_uuid_v4(&input.draft_id, "draftId")?; - let preflight = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - if let Some(existing) = - read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - { - if existing.intent != input.intent || existing.source_asset_id != input.source_asset_id { - return Err("draftId 已绑定到不同的素材画布请求".to_string()); - } - return Ok(CreateAssetCanvasDraftResult { - status: CreateAssetCanvasDraftStatus::Existing, - draft: existing, - }); - } - if preflight.project_id != manifest.project_id { - return Err("项目身份在素材画布锁获取期间发生变化".to_string()); - } - let now = asset_canvas_now(); - let (source_resource_id, canvas) = match input.intent { - AssetCanvasIntent::Create => { - if input.source_asset_id.is_some() { - return Err("create 草稿不能指定 sourceAssetId".to_string()); - } - (None, default_asset_canvas_state()) - } - AssetCanvasIntent::Refine => { - let source_asset_id = input - .source_asset_id - .as_deref() - .ok_or_else(|| "refine 草稿必须指定 sourceAssetId".to_string())?; - let (asset, bytes, width, height) = - project_asset_image(root, &manifest, source_asset_id)?; - let resource_id = asset_canvas_source_resource_id(asset); - let media_id = new_asset_canvas_token()?; - let media_type = asset.media_type.clone(); - let source_media_ref = AssetCanvasMediaRef::DraftMedia { - media_id: media_id.clone(), - media_type: media_type.clone(), - sha256: asset_canvas_sha256(&bytes), - byte_length: bytes.len() as u64, - pixel_width: width, - pixel_height: height, - }; - let media_path = resolve_local_project_path( - root, - &format!( - "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{}", - input.draft_id, - media_extension(&media_type)? - ), - )?; - install_new_asset_canvas_file(&media_path, &bytes, "精修源图片快照")?; - let layer_id = Uuid::new_v4().to_string(); - let mut canvas = default_asset_canvas_state(); - canvas.layers.push(AssetCanvasLayer { - layer_id: layer_id.clone(), - resource_id: resource_id.clone(), - title: asset.id.clone(), - media_ref: source_media_ref, - x: 6000.0 - f64::from(width) / 2.0, - y: 6000.0 - f64::from(height) / 2.0, - width: f64::from(width), - height: f64::from(height), - original_width: f64::from(width), - original_height: f64::from(height), - z_index: 0, - group_id: None, - hidden: false, - locked: false, - flip_x: false, - flip_y: false, - }); - canvas.selected_layer_ids.push(layer_id.clone()); - canvas.primary_selected_layer_id = Some(layer_id); - (Some(resource_id), canvas) - } - }; - let draft = AssetCanvasDraft { - schema_version: ASSET_CANVAS_SCHEMA_VERSION.to_string(), - draft_id: input.draft_id.clone(), - project_id: manifest.project_id, - intent: input.intent.clone(), - source_asset_id: input.source_asset_id.clone(), - source_resource_id, - revision: 0, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: Vec::new(), - pending_commit: None, - last_commit: None, - created_at: now, - updated_at: now, - }; - if let Err(error) = write_asset_canvas_draft_locked(root, &draft) { - if let Some(source_layer) = draft.canvas.layers.first() { - if let AssetCanvasMediaRef::DraftMedia { - media_id, - media_type, - .. - } = &source_layer.media_ref - { - if let Ok(extension) = media_extension(media_type) { - let media_path = resolve_local_project_path( - root, - &format!( - "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}", - draft.draft_id - ), - ); - if let Ok(media_path) = media_path { - let _ = fs::remove_file(media_path); - } - } - } - } - return Err(error); - } - Ok(CreateAssetCanvasDraftResult { - status: CreateAssetCanvasDraftStatus::Created, - draft, - }) -} - -pub(crate) fn read_asset_canvas_draft_at( - root: &Path, - input: &ReadAssetCanvasDraftInput, -) -> Result { - validate_uuid_v4(&input.draft_id, "draftId")?; - let manifest = match validate_asset_canvas_project_identity(root, &input.expected_project_id) { - Ok(manifest) => manifest, - Err(error) if error.contains("expectedProjectId") => { - return Ok(ReadAssetCanvasDraftResult { - status: ReadAssetCanvasDraftStatus::ProjectIdentityConflict, - draft: None, - }); - } - Err(error) => return Err(error), - }; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &manifest.project_id)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?; - Ok(ReadAssetCanvasDraftResult { - status: if draft.is_some() { - ReadAssetCanvasDraftStatus::Found - } else { - ReadAssetCanvasDraftStatus::NotFound - }, - draft, - }) -} - -pub(crate) fn discover_asset_canvas_draft_at( - root: &Path, - input: &DiscoverAssetCanvasDraftInput, -) -> Result { - match (&input.intent, input.source_asset_id.as_deref()) { - (AssetCanvasIntent::Create, None) => {} - (AssetCanvasIntent::Refine, Some(source_asset_id)) => { - validate_plain_component(source_asset_id, "sourceAssetId", 512)?; - } - (AssetCanvasIntent::Create, Some(_)) => { - return Err("create 草稿发现不能指定 sourceAssetId".to_string()); - } - (AssetCanvasIntent::Refine, None) => { - return Err("refine 草稿发现必须指定 sourceAssetId".to_string()); - } - } - let preflight = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &preflight.project_id)?; - if preflight.project_id != manifest.project_id { - return Err("项目身份在素材画布草稿发现期间发生变化".to_string()); - } - let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/drafts"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(DiscoverAssetCanvasDraftResult { - status: DiscoverAssetCanvasDraftStatus::NotFound, - draft: None, - }); - } - Err(error) => return Err(format!("读取素材画布草稿目录失败:{error}")), - }; - let mut scanned_entries = 0_usize; - let mut matching = Vec::new(); - for entry in entries { - let entry = entry.map_err(|error| format!("读取素材画布草稿条目失败:{error}"))?; - scanned_entries += 1; - if scanned_entries > ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES { - return Err("素材画布草稿数量超过安全扫描上限".to_string()); - } - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - if !entry - .file_type() - .map_err(|error| format!("读取素材画布草稿类型失败:{error}"))? - .is_file() - { - return Err("素材画布草稿条目必须是普通 JSON 文件".to_string()); - } - let draft_id = path - .file_stem() - .and_then(|value| value.to_str()) - .ok_or_else(|| "素材画布草稿文件名无效".to_string())?; - validate_uuid_v4(draft_id, "草稿文件名")?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? - .ok_or_else(|| "素材画布草稿扫描结果不一致".to_string())?; - if draft.intent == input.intent - && draft.source_asset_id == input.source_asset_id - && matches!( - draft.status, - AssetCanvasDraftStatus::Editing - | AssetCanvasDraftStatus::Generating - | AssetCanvasDraftStatus::CommitPrepared - | AssetCanvasDraftStatus::ReconciliationRequired - ) - { - matching.push(draft); - } - } - match matching.len() { - 0 => Ok(DiscoverAssetCanvasDraftResult { - status: DiscoverAssetCanvasDraftStatus::NotFound, - draft: None, - }), - 1 => Ok(DiscoverAssetCanvasDraftResult { - status: DiscoverAssetCanvasDraftStatus::Found, - draft: matching.pop(), - }), - _ => Err( - "reconciliation-required: 同一来源存在多个可恢复素材画布草稿,不能猜测恢复目标" - .to_string(), - ), - } -} - -pub(crate) fn update_asset_canvas_draft_at( - root: &Path, - input: &UpdateAssetCanvasDraftInput, -) -> Result { - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - if !matches!( - input.status, - AssetCanvasDraftStatus::Editing - | AssetCanvasDraftStatus::Generating - | AssetCanvasDraftStatus::Cancelled - ) { - return Err("前端不能推进素材画布事务终态".to_string()); - } - validate_asset_canvas_state(&input.canvas)?; - validate_generation_records(&input.generations)?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let mut 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 Ok(UpdateAssetCanvasDraftResult { - status: UpdateAssetCanvasDraftStatus::Conflict, - draft, - }); - } - if matches!( - draft.status, - AssetCanvasDraftStatus::CommitPrepared - | AssetCanvasDraftStatus::Committed - | AssetCanvasDraftStatus::ReconciliationRequired - ) { - return Err("素材画布草稿正在提交或已进入事务终态".to_string()); - } - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.status = input.status.clone(); - draft.canvas = protect_candidate_layers_before_frontend_update_at(root, &draft, &input.canvas)?; - // Generation lifecycle facts remain backend-authoritative, while the card - // placement is ordinary canvas state and may be changed by the user. - // Merge only placeholders so an older autosave cannot erase phase, output, - // error, or identity facts written by the generation ledger. - for incoming in &input.generations { - if let Some(current) = draft - .generations - .iter_mut() - .find(|record| record.generation_id == incoming.generation_id) - { - if current.intent_id != incoming.intent_id { - return Err("generationId 已绑定到不同 intentId".to_string()); - } - current.placeholder = incoming.placeholder.clone(); - } else { - if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS { - return Err("素材画布生成记录数量已达上限".to_string()); - } - draft.generations.push(incoming.clone()); - } - } - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - Ok(UpdateAssetCanvasDraftResult { - status: UpdateAssetCanvasDraftStatus::Updated, - draft, - }) -} - -fn rollback_new_asset_canvas_files(paths: &[PathBuf], label: &str) -> Result<(), String> { - let mut failed = 0_usize; - for path in paths.iter().rev() { - match fs::remove_file(path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => failed += 1, - } - } - if failed > 0 { - Err(format!( - "{label} 回滚失败,需要对账:{failed} 个新文件未能删除" - )) - } else { - Ok(()) - } -} - -fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), String> { - let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; - if fs::symlink_metadata(path).is_ok() { - return Err(format!("{label} 目标已存在")); - } - let temp_path = path.with_file_name(format!(".asset-canvas-{}.tmp", Uuid::new_v4())); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags(libc::O_NOFOLLOW); - } - let mut file = options - .open(&temp_path) - .map_err(|_| format!("创建 {label} 临时文件失败"))?; - if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { - let _ = fs::remove_file(&temp_path); - return Err(format!("写入 {label} 临时文件失败:{error}")); - } - drop(file); - if let Err(error) = fs::hard_link(&temp_path, path) { - let install_error = format!("原子安装 {label} 失败:{error}"); - return match rollback_new_asset_canvas_files(&[temp_path], label) { - Ok(()) => Err(install_error), - Err(rollback_error) => Err(format!("{install_error};{rollback_error}")), - }; - } - - let completion = (|| -> Result<(), String> { - fs::remove_file(&temp_path).map_err(|error| format!("完成 {label} 安装失败:{error}"))?; - let (_, metadata) = open_project_snapshot_regular_file(path, label)?; - if metadata.len() != bytes.len() as u64 { - return Err(format!("{label} 安装后大小不一致")); - } - #[cfg(unix)] - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; - Ok(()) - })(); - if let Err(error) = completion { - let rollback_paths = [path.to_path_buf(), temp_path]; - return match rollback_new_asset_canvas_files(&rollback_paths, label) { - Ok(()) => Err(error), - Err(rollback_error) => Err(format!("{error};{rollback_error}")), - }; - } - Ok(()) -} -fn asset_canvas_draft_media_bytes(root: &Path, draft_id: &str) -> Result { - let directory = - resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/media/{draft_id}"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(_) => return Err("读取素材画布媒体目录失败".to_string()), - }; - let mut total = 0_u64; - let mut count = 0_usize; - for entry in entries { - let entry = entry.map_err(|_| "读取素材画布媒体条目失败".to_string())?; - count += 1; - if count > ASSET_CANVAS_MAX_LAYERS * 2 { - return Err("素材画布媒体条目数量超限".to_string()); - } - let (_, metadata) = open_project_snapshot_regular_file(&entry.path(), "素材画布媒体")?; - total = total - .checked_add(metadata.len()) - .ok_or_else(|| "素材画布媒体总量溢出".to_string())?; - if total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES { - return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); - } - } - Ok(total) -} - -#[cfg(windows)] -fn asset_canvas_import_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 -} - -#[cfg(not(windows))] -fn asset_canvas_import_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { - false -} - -fn asset_canvas_import_media_type(bytes: &[u8]) -> Result<&'static str, String> { - if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - Ok("image/png") - } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { - Ok("image/jpeg") - } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { - Ok("image/webp") - } else { - Err("只支持 PNG、JPEG 和 WebP 图片".to_string()) - } -} - -fn sanitize_asset_canvas_import_title(path: &Path, index: usize) -> String { - let title = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("") - .chars() - .filter(|value| !value.is_control()) - .take(200) - .collect::(); - if title.trim().is_empty() { - format!("导入图片 {}", index + 1) - } else { - title - } -} - -fn read_asset_canvas_import( - path: &Path, - index: usize, -) -> Result { - if !path.is_absolute() { - return Err(format!("第 {} 张导入图片路径必须是绝对路径", index + 1)); - } - let path_metadata = fs::symlink_metadata(path) - .map_err(|error| format!("读取第 {} 张导入图片失败:{error}", index + 1))?; - if path_metadata.file_type().is_symlink() - || asset_canvas_import_metadata_is_reparse_point(&path_metadata) - || !path_metadata.is_file() - { - return Err(format!("第 {} 张导入图片必须是普通文件", index + 1)); - } - if path_metadata.len() == 0 || path_metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 { - return Err(format!( - "第 {} 张导入图片大小必须在 1..={ASSET_CANVAS_MAX_MEDIA_BYTES} 字节", - index + 1 - )); - } - let (mut file, metadata) = open_project_snapshot_regular_file(path, "导入图片")?; - if metadata.len() != path_metadata.len() || metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 - { - return Err(format!("第 {} 张导入图片在读取期间发生变化", index + 1)); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take((ASSET_CANVAS_MAX_MEDIA_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| format!("读取第 {} 张导入图片失败:{error}", index + 1))?; - let media_type = asset_canvas_import_media_type(&bytes)?; - let (media_type, width, height) = validate_image_bytes(media_type, &bytes)?; - Ok(ValidatedAssetCanvasImport { - title: sanitize_asset_canvas_import_title(path, index), - media_type, - bytes, - width, - height, - }) -} - -fn asset_canvas_import_display_size( - width: u32, - height: u32, - viewport_width: f64, - viewport_height: f64, - viewport_scale: f64, -) -> (f64, f64) { - let original_width = f64::from(width); - let original_height = f64::from(height); - let visible_width = viewport_width / viewport_scale; - let visible_height = viewport_height / viewport_scale; - let minimum_scale = if original_width.max(original_height) < 160.0 { - 160.0 / original_width.max(original_height) - } else { - 1.0 - }; - let maximum_scale = (visible_width * 0.6 / original_width) - .min(visible_height * 0.6 / original_height) - .min(1024.0 / original_width) - .min(1024.0 / original_height); - let scale = minimum_scale.min(maximum_scale).max(0.01); - (original_width * scale, original_height * scale) -} - -fn asset_canvas_layers_overlap( - x: f64, - y: f64, - width: f64, - height: f64, - layers: &[AssetCanvasLayer], -) -> bool { - layers.iter().filter(|layer| !layer.hidden).any(|layer| { - x < layer.x + layer.width + 16.0 - && x + width + 16.0 > layer.x - && y < layer.y + layer.height + 16.0 - && y + height + 16.0 > layer.y - }) -} - -pub(crate) fn import_asset_canvas_images_at( - root: &Path, - input: &ImportAssetCanvasImagesInput, - paths: &[PathBuf], -) -> Result { - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - if !input.viewport_width.is_finite() - || !input.viewport_height.is_finite() - || input.viewport_width <= 0.0 - || input.viewport_height <= 0.0 - || input.viewport_width > 16_384.0 - || input.viewport_height > 16_384.0 - { - return Err("素材画布导入 viewport 尺寸无效".to_string()); - } - if paths.is_empty() || paths.len() > ASSET_CANVAS_MAX_IMPORT_BATCH { - return Err(format!( - "单次只能导入 1..={ASSET_CANVAS_MAX_IMPORT_BATCH} 张图片" - )); - } - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let mut imports = Vec::with_capacity(paths.len()); - let mut batch_bytes = 0_u64; - for (index, path) in paths.iter().enumerate() { - let imported = read_asset_canvas_import(path, index)?; - batch_bytes = batch_bytes - .checked_add(imported.bytes.len() as u64) - .ok_or_else(|| "导入图片总量溢出".to_string())?; - if batch_bytes > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES { - return Err("单批导入图片总量超过 512 MiB".to_string()); - } - imports.push(imported); - } - - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let mut 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 Ok(ImportAssetCanvasImagesResult { - status: ImportAssetCanvasImagesStatus::Conflict, - draft: Some(draft), - imported_layer_ids: Vec::new(), - }); - } - if !matches!(draft.status, AssetCanvasDraftStatus::Editing) { - return Err("素材画布草稿当前状态不允许导入图片".to_string()); - } - if draft.canvas.layers.len() + imports.len() > ASSET_CANVAS_MAX_LAYERS { - return Err(format!("素材画布最多支持 {ASSET_CANVAS_MAX_LAYERS} 个图层")); - } - let existing_bytes = asset_canvas_draft_media_bytes(root, &input.draft_id)?; - if existing_bytes - .checked_add(batch_bytes) - .is_none_or(|total| total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) - { - return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); - } - - // 先完成所有可能失败的身份、路径和画布计算,再安装文件。 - // 避免后续层序、revision 或路径错误留下部分导入媒体。 - let mut prepared_installs = Vec::with_capacity(imports.len()); - let mut imported_layer_ids = Vec::with_capacity(imports.len()); - let mut next_z = draft - .canvas - .layers - .iter() - .map(|layer| layer.z_index) - .max() - .unwrap_or(0); - let has_existing_layer = !draft.canvas.layers.is_empty(); - let center_x = - (input.viewport_width / 2.0 - draft.canvas.viewport.x) / draft.canvas.viewport.scale; - let center_y = - (input.viewport_height / 2.0 - draft.canvas.viewport.y) / draft.canvas.viewport.scale; - for (index, imported) in imports.into_iter().enumerate() { - let media_id = new_asset_canvas_token()?; - let extension = media_extension(&imported.media_type)?; - let relative = format!( - "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}", - input.draft_id - ); - let path = resolve_local_project_path(root, &relative)?; - let media_ref = AssetCanvasMediaRef::DraftMedia { - media_id: media_id.clone(), - media_type: imported.media_type, - sha256: asset_canvas_sha256(&imported.bytes), - byte_length: imported.bytes.len() as u64, - pixel_width: imported.width, - pixel_height: imported.height, - }; - let layer_id = Uuid::new_v4().to_string(); - let (display_width, display_height) = asset_canvas_import_display_size( - imported.width, - imported.height, - input.viewport_width, - input.viewport_height, - draft.canvas.viewport.scale, - ); - let offset = 36.0 / draft.canvas.viewport.scale; - let mut x = (center_x - display_width / 2.0 + index as f64 * offset) - .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_width).max(0.0)); - let mut y = (center_y - display_height / 2.0 + index as f64 * offset) - .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_height).max(0.0)); - for attempt in 0..64 { - if !asset_canvas_layers_overlap( - x, - y, - display_width, - display_height, - &draft.canvas.layers, - ) { - break; - } - let column = (attempt % 8 + 1) as f64; - let row = (attempt / 8) as f64; - x = (center_x - display_width / 2.0 + column * offset) - .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_width).max(0.0)); - y = (center_y - display_height / 2.0 + row * offset) - .clamp(0.0, (ASSET_CANVAS_WORLD_SIZE - display_height).max(0.0)); - } - if has_existing_layer || index > 0 { - next_z = next_z - .checked_add(1) - .ok_or_else(|| "素材画布图层层序已达到上限".to_string())?; - } - validate_safe_revision(next_z, "zIndex")?; - draft.canvas.layers.push(AssetCanvasLayer { - layer_id: layer_id.clone(), - resource_id: format!("draft-media:{media_id}"), - title: imported.title, - media_ref, - x, - y, - width: display_width, - height: display_height, - original_width: f64::from(imported.width), - original_height: f64::from(imported.height), - z_index: next_z, - group_id: None, - hidden: false, - locked: false, - flip_x: false, - flip_y: false, - }); - prepared_installs.push((path, imported.bytes)); - imported_layer_ids.push(layer_id); - } - draft.canvas.selected_layer_ids = imported_layer_ids.clone(); - draft.canvas.primary_selected_layer_id = imported_layer_ids.last().cloned(); - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = asset_canvas_now(); - - let mut installed_paths = Vec::with_capacity(prepared_installs.len()); - for (path, bytes) in &prepared_installs { - if let Err(error) = install_new_asset_canvas_file(path, bytes, "素材画布导入媒体") { - return match rollback_new_asset_canvas_files(&installed_paths, "素材画布批量导入") - { - Ok(()) => Err(error), - Err(rollback_error) => Err(format!("{error};{rollback_error}")), - }; - } - installed_paths.push(path.clone()); - } - if let Err(error) = write_asset_canvas_draft_locked(root, &draft) { - match read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id) { - Ok(Some(candidate)) if candidate == draft => {} - Ok(_) => { - return match rollback_new_asset_canvas_files(&installed_paths, "素材画布批量导入") - { - Ok(()) => Err(error), - Err(rollback_error) => Err(format!("{error};{rollback_error}")), - }; - } - Err(read_error) => { - // 草稿提交结果未知时保留媒体,避免删除一个已落盘草稿正在引用的文件。 - return Err(format!( - "素材画布批量导入写入结果无法确认,需要对账:{error};{read_error}" - )); - } - } - } - Ok(ImportAssetCanvasImagesResult { - status: ImportAssetCanvasImagesStatus::Imported, - draft: Some(draft), - imported_layer_ids, - }) -} - -pub(crate) fn store_asset_canvas_media_at( - root: &Path, - input: &StoreAssetCanvasMediaInput, -) -> Result { - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - let (media_type, width, height) = validate_image_bytes(&input.media_type, &input.bytes)?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - 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 Ok(StoreAssetCanvasMediaResult { - status: "conflict".to_string(), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_ref: None, - draft: Some(draft), - }); - } - if asset_canvas_draft_media_bytes(root, &input.draft_id)? - .checked_add(input.bytes.len() as u64) - .is_none_or(|total| total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) - { - return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); - } - let media_id = new_asset_canvas_token()?; - let extension = media_extension(&media_type)?; - let relative = format!( - "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}", - input.draft_id - ); - let path = resolve_local_project_path(root, &relative)?; - install_new_asset_canvas_file(&path, &input.bytes, "素材画布媒体")?; - let media_ref = AssetCanvasMediaRef::DraftMedia { - media_id, - media_type, - sha256: asset_canvas_sha256(&input.bytes), - byte_length: input.bytes.len() as u64, - pixel_width: width, - pixel_height: height, - }; - Ok(StoreAssetCanvasMediaResult { - status: "stored".to_string(), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_ref: Some(media_ref), - draft: None, - }) -} - -pub(crate) fn stage_asset_canvas_image_at( - root: &Path, - input: &StageAssetCanvasImageInput, -) -> Result { - stage_asset_canvas_image_with_token_at(root, input, None) -} - -fn stage_asset_canvas_image_with_token_at( - root: &Path, - input: &StageAssetCanvasImageInput, - stable_token: Option<&str>, -) -> Result { - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - let (media_type, width, height) = validate_image_bytes(&input.media_type, &input.bytes)?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if matches!( - draft.status, - AssetCanvasDraftStatus::Cancelled | AssetCanvasDraftStatus::Committed - ) { - return Err("素材画布草稿已取消或提交,不能继续暂存图片".to_string()); - } - if draft.revision != input.expected_draft_revision { - return Ok(StageAssetCanvasImageResult { - status: "conflict".to_string(), - staged_image_token: None, - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_type: None, - sha256: None, - byte_length: None, - pixel_width: None, - pixel_height: None, - expires_at: None, - draft: Some(draft), - }); - } - let token = match stable_token { - Some(token) => { - validate_uuid_v4(token, "stagedImageToken")?; - token.to_string() - } - None => new_asset_canvas_token()?, - }; - let extension = media_extension(&media_type)?; - let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"); - let image_path = resolve_local_project_path(root, &image_relative)?; - let expires_at = asset_canvas_now() - .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) - .min(ASSET_CANVAS_MAX_SAFE_INTEGER); - let expected_metadata = AssetCanvasStagedImage { - schema_version: "game-creator-asset-canvas-staging.v1".to_string(), - project_id: manifest.project_id.clone(), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - staged_image_token: token.clone(), - media_type: media_type.clone(), - sha256: asset_canvas_sha256(&input.bytes), - byte_length: input.bytes.len() as u64, - pixel_width: width, - pixel_height: height, - expires_at, - }; - if stable_token.is_some() { - let existing_metadata = read_staged_image_metadata_locked(root, &token)?; - if existing_metadata.as_ref().is_some_and(|metadata| { - metadata.project_id != expected_metadata.project_id - || metadata.draft_id != expected_metadata.draft_id - || metadata.draft_revision != expected_metadata.draft_revision - || metadata.media_type != expected_metadata.media_type - || metadata.sha256 != expected_metadata.sha256 - || metadata.byte_length != expected_metadata.byte_length - || metadata.pixel_width != expected_metadata.pixel_width - || metadata.pixel_height != expected_metadata.pixel_height - }) { - return Err("稳定 staging token 已绑定到不同图片".to_string()); - } - for candidate_extension in ["png", "jpg", "webp"] { - if candidate_extension == extension { - continue; - } - let candidate = resolve_local_project_path( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{candidate_extension}"), - )?; - match fs::symlink_metadata(candidate) { - Ok(_) => return Err("稳定 staging token 已绑定到不同图片".to_string()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("读取素材画布 staging 图片失败".to_string()), - } - } - let existing_image = match fs::symlink_metadata(&image_path) { - Ok(_) => Some(open_and_validate_image_file( - &image_path, - &media_type, - Some(&expected_metadata.sha256), - )?), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(_) => return Err("读取素材画布 staging 图片失败".to_string()), - }; - if existing_image - .as_ref() - .is_some_and(|(bytes, existing_width, existing_height)| { - bytes != &input.bytes || *existing_width != width || *existing_height != height - }) - { - return Err("稳定 staging token 已绑定到不同图片".to_string()); - } - if existing_image.is_none() { - install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?; - } - let metadata = existing_metadata.unwrap_or(expected_metadata); - if read_staged_image_metadata_locked(root, &token)?.is_none() { - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), - "素材画布 staging 元数据", - &metadata, - 16 * 1024, - )?; - } - let (metadata, existing_bytes) = read_staged_image_locked(root, &token)?; - if existing_bytes != input.bytes { - return Err("稳定 staging token 已绑定到不同图片".to_string()); - } - return Ok(StageAssetCanvasImageResult { - status: "staged".to_string(), - staged_image_token: Some(token), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_type: Some(metadata.media_type), - sha256: Some(metadata.sha256), - byte_length: Some(metadata.byte_length), - pixel_width: Some(metadata.pixel_width), - pixel_height: Some(metadata.pixel_height), - expires_at: Some(metadata.expires_at), - draft: None, - }); - } - install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?; - let metadata = expected_metadata; - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), - "素材画布 staging 元数据", - &metadata, - 16 * 1024, - )?; - Ok(StageAssetCanvasImageResult { - status: "staged".to_string(), - staged_image_token: Some(token), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_type: Some(media_type), - sha256: Some(metadata.sha256), - byte_length: Some(metadata.byte_length), - pixel_width: Some(width), - pixel_height: Some(height), - expires_at: Some(expires_at), - draft: None, - }) -} - -fn rebind_asset_canvas_staged_image_revision_locked( - root: &Path, - staged_image_token: &str, - expected_project_id: &str, - draft_id: &str, - expected_previous_revision: u64, - next_revision: u64, -) -> Result<(), String> { - 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())?; - if draft.revision != next_revision { - return Err("staging 不能绑定到非当前草稿 revision".to_string()); - } - let (mut metadata, _) = read_staged_image_locked(root, staged_image_token)?; - if metadata.project_id != manifest.project_id || metadata.draft_id != draft_id { - return Err("staging 图片与当前项目草稿身份不匹配".to_string()); - } - if metadata.draft_revision == next_revision { - return Ok(()); - } - if metadata.draft_revision != expected_previous_revision { - return Err("staging 图片 revision 已被其它流程推进".to_string()); - } - metadata.draft_revision = next_revision; - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{staged_image_token}/metadata.json"), - "素材画布 staging 元数据", - &metadata, - 16 * 1024, - ) -} - -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, -) -> Result<(String, String, Option), String> { - match media_ref { - AssetCanvasMediaRef::DraftMedia { - media_id, - media_type, - sha256, - .. - } => { - validate_plain_component(media_id, "mediaId", 128)?; - let extension = media_extension(media_type)?; - Ok(( - format!("{ASSET_CANVAS_ROOT}/media/{draft_id}/{media_id}.{extension}"), - media_type.clone(), - Some(sha256.clone()), - )) - } - AssetCanvasMediaRef::ProjectAsset { .. } => { - Err("project asset 需要通过 manifest 解析".to_string()) - } - } -} - -pub(crate) fn read_asset_canvas_media_at( - root: &Path, - input: &ReadAssetCanvasMediaInput, -) -> Result { - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if !draft - .canvas - .layers - .iter() - .any(|layer| layer.media_ref == input.media_ref) - { - return Err("媒体引用不属于当前草稿".to_string()); - } - match &input.media_ref { - AssetCanvasMediaRef::ProjectAsset { asset_id } => { - let (asset, bytes, _, _) = project_asset_image(root, &manifest, asset_id)?; - Ok(ReadAssetCanvasMediaResult { - media_type: asset.media_type.clone(), - bytes, - }) - } - media_ref => { - let (relative, media_type, sha256) = - draft_media_relative_path(&input.draft_id, media_ref)?; - let (bytes, _, _) = open_and_validate_image_file( - &resolve_local_project_path(root, &relative)?, - &media_type, - sha256.as_deref(), - )?; - Ok(ReadAssetCanvasMediaResult { media_type, bytes }) - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AssetCanvasGenerationProvenance { - pub(crate) task_id: String, - pub(crate) prompt: String, - pub(crate) model: String, - pub(crate) generation_route: String, - pub(crate) generation_kind: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CommitAssetCanvasInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) expected_revision: u64, - pub(crate) expected_draft_revision: u64, - pub(crate) draft_id: String, - pub(crate) commit_id: String, - pub(crate) idempotency_key: String, - pub(crate) intent: AssetCanvasIntent, - pub(crate) source_asset_id: Option, - pub(crate) staged_image_token: String, - #[serde(default)] - pub(crate) source_layer_id: Option, - #[serde(default)] - pub(crate) media_sha256: Option, - pub(crate) name: String, - pub(crate) asset_kind: String, - pub(crate) reference_resource_ids: Vec, - pub(crate) generation_provenance: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct CommitAssetCanvasCandidateInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) expected_revision: u64, - pub(crate) expected_draft_revision: u64, - pub(crate) draft_id: String, - pub(crate) commit_id: String, - pub(crate) idempotency_key: String, - pub(crate) source_asset_id: Option, - pub(crate) source_layer_id: String, - pub(crate) name: String, - pub(crate) asset_kind: String, - pub(crate) reference_resource_ids: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum AssetCanvasConflictKind { - ProjectIdentity, - ProjectRevision, - DraftRevision, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "status", rename_all = "kebab-case")] -pub(crate) enum CommitAssetCanvasResult { - Committed { - #[serde(rename = "projectId")] - project_id: String, - #[serde(rename = "projectRevision")] - project_revision: u64, - #[serde(rename = "committedProjectRevision")] - committed_project_revision: u64, - #[serde(rename = "draftId")] - draft_id: String, - #[serde(rename = "draftRevision")] - draft_revision: u64, - #[serde(rename = "commitId")] - commit_id: String, - #[serde(rename = "idempotencyKey")] - idempotency_key: String, - #[serde(rename = "eventId")] - event_id: String, - asset: GameCreationAppAssetManifestEntry, - manifest: GameCreationAppManifest, - }, - AlreadyCommitted { - #[serde(rename = "projectId")] - project_id: String, - #[serde(rename = "projectRevision")] - project_revision: u64, - #[serde(rename = "committedProjectRevision")] - committed_project_revision: u64, - #[serde(rename = "draftId")] - draft_id: String, - #[serde(rename = "draftRevision")] - draft_revision: u64, - #[serde(rename = "commitId")] - commit_id: String, - #[serde(rename = "idempotencyKey")] - idempotency_key: String, - #[serde(rename = "eventId")] - event_id: String, - asset: GameCreationAppAssetManifestEntry, - manifest: GameCreationAppManifest, - }, - Conflict { - #[serde(rename = "conflictKind")] - conflict_kind: AssetCanvasConflictKind, - #[serde(rename = "expectedProjectId")] - expected_project_id: String, - #[serde(rename = "projectId")] - project_id: Option, - #[serde(rename = "expectedRevision")] - expected_revision: u64, - #[serde(rename = "projectRevision")] - project_revision: Option, - #[serde(rename = "expectedDraftRevision")] - expected_draft_revision: u64, - #[serde(rename = "draftRevision")] - draft_revision: Option, - #[serde(rename = "commitId")] - commit_id: String, - #[serde(rename = "idempotencyKey")] - idempotency_key: String, - asset: Option, - manifest: Option, - }, - RolledBack { - #[serde(rename = "projectId")] - project_id: String, - #[serde(rename = "projectRevision")] - project_revision: u64, - #[serde(rename = "draftId")] - draft_id: String, - #[serde(rename = "draftRevision")] - draft_revision: u64, - #[serde(rename = "commitId")] - commit_id: String, - #[serde(rename = "idempotencyKey")] - idempotency_key: String, - #[serde(rename = "eventId")] - event_id: String, - asset: Option, - manifest: GameCreationAppManifest, - }, - ReconciliationRequired { - #[serde(rename = "projectId")] - project_id: String, - #[serde(rename = "projectRevision")] - project_revision: u64, - #[serde(rename = "draftId")] - draft_id: String, - #[serde(rename = "draftRevision")] - draft_revision: u64, - #[serde(rename = "commitId")] - commit_id: String, - #[serde(rename = "idempotencyKey")] - idempotency_key: String, - #[serde(rename = "eventId")] - event_id: String, - asset: Option, - manifest: GameCreationAppManifest, - }, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AssetCanvasCommittedEventBody { - schema_version: String, - event_id: String, - project_id: String, - committed_project_revision: u64, - draft_id: String, - commit_id: String, - idempotency_key: String, - asset: GameCreationAppAssetManifestEntry, - manifest: GameCreationAppManifest, - occurred_at: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AssetCanvasCommittedEvent { - pub(crate) schema_version: String, - pub(crate) event_id: String, - pub(crate) project_path: String, - pub(crate) project_id: String, - pub(crate) committed_project_revision: u64, - pub(crate) draft_id: String, - pub(crate) commit_id: String, - pub(crate) idempotency_key: String, - pub(crate) asset: GameCreationAppAssetManifestEntry, - pub(crate) manifest: GameCreationAppManifest, - pub(crate) occurred_at: u64, -} - -pub(crate) fn asset_canvas_committed_public_event( - event: &AssetCanvasCommittedEvent, -) -> serde_json::Value { - serde_json::json!({ - "schemaVersion": event.schema_version, - "eventId": event.event_id, - "projectPath": event.project_path, - "projectId": event.project_id, - "committedProjectRevision": event.committed_project_revision, - "draftId": event.draft_id, - "commitId": event.commit_id, - "asset": event.asset, - "manifest": event.manifest, - "occurredAt": event.occurred_at, - }) -} - -impl AssetCanvasCommittedEventBody { - fn with_project_path(&self, root: &Path) -> AssetCanvasCommittedEvent { - AssetCanvasCommittedEvent { - schema_version: self.schema_version.clone(), - event_id: self.event_id.clone(), - project_path: root.to_string_lossy().into_owned(), - project_id: self.project_id.clone(), - committed_project_revision: self.committed_project_revision, - draft_id: self.draft_id.clone(), - commit_id: self.commit_id.clone(), - idempotency_key: self.idempotency_key.clone(), - asset: self.asset.clone(), - manifest: self.manifest.clone(), - occurred_at: self.occurred_at, - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum AssetCanvasLedgerStatus { - Prepared, - Committed, - RolledBack, - ReconciliationRequired, - Superseded, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum AssetCanvasEventDelivery { - Pending, - Attempted, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AssetCanvasCommitLedger { - schema_version: String, - project_id: String, - draft_id: String, - commit_id: String, - idempotency_key: String, - request_fingerprint: String, - status: AssetCanvasLedgerStatus, - expected_project_revision: u64, - committed_project_revision: Option, - expected_draft_revision: u64, - committed_draft_revision: Option, - #[serde(default)] - source_layer_id: String, - #[serde(default)] - media_sha256: String, - asset_id: Option, - event_id: String, - event_payload: Option, - event_delivery: AssetCanvasEventDelivery, - created_at: u64, - updated_at: u64, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum AssetCanvasTransactionStage { - Prepared, - FileInstalled, - ManifestInstalled, - RevisionInstalled, - Verified, - Committed, - EventAttempted, - RolledBack, - ReconciliationRequired, - Superseded, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AssetCanvasTransactionJournal { - schema_version: String, - project_id: String, - draft_id: String, - commit_id: String, - idempotency_key: String, - request_fingerprint: String, - stage: AssetCanvasTransactionStage, - expected_project_revision: u64, - target_project_revision: u64, - expected_draft_revision: u64, - target_draft_revision: u64, - staged_image: AssetCanvasStagedImage, - #[serde(default)] - source_layer_id: String, - #[serde(default)] - media_sha256: String, - final_image_relative_path: String, - final_image_sha256: String, - final_image_existed_before: bool, - #[serde(default)] - runtime_entry_relative_path: Option, - #[serde(default)] - runtime_entry_refreshed: bool, - manifest_before_sha256: String, - manifest_after_sha256: String, - project_revision_before_sha256: Option, - project_revision_after_sha256: String, - asset_id: String, - event_id: String, - occurred_at: u64, - created_at: u64, - updated_at: u64, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct NormalizedAssetCanvasCommitRequest<'a> { - expected_project_id: &'a str, - expected_revision: u64, - expected_draft_revision: u64, - draft_id: &'a str, - commit_id: &'a str, - idempotency_key: &'a str, - intent: &'a AssetCanvasIntent, - source_asset_id: &'a Option, - staged_image_token: &'a str, - source_layer_id: &'a Option, - media_sha256: &'a Option, - name: &'a str, - asset_kind: &'a str, - reference_resource_ids: &'a [String], - generation_provenance: &'a Option, -} - -#[derive(Debug)] -pub(crate) struct CommitAssetCanvasExecution { - pub(crate) result: CommitAssetCanvasResult, - pub(crate) event: Option, -} - -fn asset_canvas_commit_relative_path(commit_id: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/commits/{commit_id}.json") -} - -fn asset_canvas_transaction_relative_path(commit_id: &str, file: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/transactions/{commit_id}/{file}") -} - -fn validate_asset_canvas_name(name: &str) -> Result { - let name = name.nfc().collect::(); - let upper = name.trim_end_matches([' ', '.']).to_ascii_uppercase(); - let stem = upper.split('.').next().unwrap_or_default(); - const WINDOWS_RESERVED: [&str; 22] = [ - "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", - "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - ]; - if name.is_empty() - || name.chars().count() > 80 - || name - .chars() - .any(|value| value.is_control() || "/\\:*?\"<>|".contains(value)) - || matches!(name.as_str(), "." | "..") - || name.ends_with([' ', '.']) - || WINDOWS_RESERVED.contains(&stem) - { - return Err("素材名称无效".to_string()); - } - Ok(name) -} - -fn normalize_asset_canvas_references(values: &[String]) -> Result, String> { - if values.len() > ASSET_CANVAS_MAX_REFERENCES { - return Err(format!( - "referenceResourceIds 最多支持 {ASSET_CANVAS_MAX_REFERENCES} 项" - )); - } - let mut normalized = BTreeSet::new(); - for value in values { - let value = value.trim(); - if value.is_empty() || value.chars().count() > 512 || value.chars().any(char::is_control) { - return Err("referenceResourceIds 包含无效资源身份".to_string()); - } - normalized.insert(value.to_string()); - } - Ok(normalized.into_iter().collect()) -} - -fn validate_asset_kind(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() - || value.len() > 64 - || !value.as_bytes()[0].is_ascii_lowercase() && !value.as_bytes()[0].is_ascii_digit() - || !value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') - }) - { - return Err("assetKind 无效".to_string()); - } - Ok(value.to_string()) -} - -fn asset_canvas_json_bytes(value: &T) -> Result, String> { - let mut bytes = - serde_json::to_vec_pretty(value).map_err(|error| format!("序列化事务文件失败:{error}"))?; - bytes.push(b'\n'); - Ok(bytes) -} - -fn read_asset_canvas_ledger( - root: &Path, - commit_id: &str, -) -> Result, String> { - read_agent_runtime_json_sidecar_with_max_bytes( - root, - &asset_canvas_commit_relative_path(commit_id), - "素材画布 commit ledger", - ASSET_CANVAS_MAX_LEDGER_BYTES, - ) -} - -fn write_asset_canvas_ledger(root: &Path, ledger: &AssetCanvasCommitLedger) -> Result<(), String> { - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &asset_canvas_commit_relative_path(&ledger.commit_id), - "素材画布 commit ledger", - ledger, - ASSET_CANVAS_MAX_LEDGER_BYTES, - ) -} - -fn read_asset_canvas_journal( - root: &Path, - commit_id: &str, -) -> Result, String> { - read_agent_runtime_json_sidecar_with_max_bytes( - root, - &asset_canvas_transaction_relative_path(commit_id, "journal.json"), - "素材画布 transaction journal", - ASSET_CANVAS_MAX_TRANSACTION_BYTES, - ) -} - -fn write_asset_canvas_journal( - root: &Path, - journal: &AssetCanvasTransactionJournal, -) -> Result<(), String> { - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &asset_canvas_transaction_relative_path(&journal.commit_id, "journal.json"), - "素材画布 transaction journal", - journal, - ASSET_CANVAS_MAX_TRANSACTION_BYTES, - ) -} - -fn read_asset_canvas_transaction_journals( - root: &Path, -) -> Result, String> { - let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/transactions"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(_) => return Err("读取素材画布 transaction 目录失败".to_string()), - }; - let mut journals = Vec::new(); - for entry in entries { - let entry = entry.map_err(|_| "读取素材画布 transaction 失败".to_string())?; - if journals.len() >= ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES { - return Err("素材画布 transaction 数量超限".to_string()); - } - let metadata = fs::symlink_metadata(entry.path()) - .map_err(|_| "读取素材画布 transaction 元数据失败".to_string())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("素材画布 transaction 必须是普通目录".to_string()); - } - let commit_id = entry.file_name().to_string_lossy().into_owned(); - validate_uuid_v4(&commit_id, "transaction commitId")?; - let Some(journal) = read_asset_canvas_journal(root, &commit_id)? else { - if read_asset_canvas_ledger(root, &commit_id)?.is_some() { - return Err("素材画布 transaction 缺少 journal".to_string()); - } - continue; - }; - if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION - || journal.commit_id != commit_id - { - return Err("素材画布 transaction journal 身份无效".to_string()); - } - journals.push(journal); - } - Ok(journals) -} - -fn asset_canvas_journal_ledger_identity_matches( - journal: &AssetCanvasTransactionJournal, - ledger: &AssetCanvasCommitLedger, -) -> bool { - ledger.schema_version == ASSET_CANVAS_COMMIT_SCHEMA_VERSION - && ledger.project_id == journal.project_id - && ledger.draft_id == journal.draft_id - && ledger.commit_id == journal.commit_id - && ledger.idempotency_key == journal.idempotency_key - && ledger.request_fingerprint == journal.request_fingerprint - && ledger.expected_project_revision == journal.expected_project_revision - && ledger.expected_draft_revision == journal.expected_draft_revision - && ledger.source_layer_id == journal.source_layer_id - && ledger.media_sha256 == journal.media_sha256 - && ledger.event_id == journal.event_id -} - -fn ensure_no_unresolved_asset_canvas_commit_for_asset( - root: &Path, - project_id: &str, - draft_id: &str, - asset_id: &str, - except_commit_id: &str, -) -> Result<(), String> { - for journal in read_asset_canvas_transaction_journals(root)? { - if journal.project_id != project_id - || journal.draft_id != draft_id - || journal.asset_id != asset_id - || journal.commit_id == except_commit_id - { - continue; - } - let ledger = read_asset_canvas_ledger(root, &journal.commit_id)?; - let unresolved = match ledger.as_ref() { - None => true, - Some(ledger) => { - if !asset_canvas_journal_ledger_identity_matches(&journal, ledger) { - return Err("素材画布 journal 与 ledger 身份不一致".to_string()); - } - matches!( - ledger.status, - AssetCanvasLedgerStatus::Prepared - | AssetCanvasLedgerStatus::ReconciliationRequired - ) - } - }; - if unresolved { - return Err( - "该资源存在未收尾的正式图提交,必须先安全恢复或完成对账后才能设为正式图" - .to_string(), - ); - } - } - Ok(()) -} - -fn find_ledger_by_idempotency_key( - root: &Path, - key: &str, -) -> Result, String> { - let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/commits"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(_) => return Err("读取素材画布 commit ledger 目录失败".to_string()), - }; - let mut count = 0usize; - for entry in entries { - let entry = entry.map_err(|_| "读取素材画布 commit ledger 失败".to_string())?; - count += 1; - if count > 4096 { - return Err("素材画布 commit ledger 数量超限".to_string()); - } - let path = entry.path(); - let (_, metadata) = open_project_snapshot_regular_file(&path, "素材画布 commit ledger")?; - if metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 { - return Err("素材画布 commit ledger 超限".to_string()); - } - let content = - fs::read_to_string(&path).map_err(|_| "读取素材画布 commit ledger 失败".to_string())?; - let ledger: AssetCanvasCommitLedger = serde_json::from_str(&content) - .map_err(|_| "解析素材画布 commit ledger 失败".to_string())?; - if ledger.idempotency_key == key { - return Ok(Some(ledger)); - } - } - Ok(None) -} - -fn read_staged_image_locked( - root: &Path, - token: &str, -) -> Result<(AssetCanvasStagedImage, Vec), String> { - validate_plain_component(token, "stagedImageToken", 128)?; - let metadata = read_staged_image_metadata_locked(root, token)? - .ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?; - let extension = media_extension(&metadata.media_type)?; - let path = resolve_local_project_path( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"), - )?; - let (bytes, width, height) = - open_and_validate_image_file(&path, &metadata.media_type, Some(&metadata.sha256))?; - if bytes.len() as u64 != metadata.byte_length - || width != metadata.pixel_width - || height != metadata.pixel_height - { - return Err("素材画布 staging 文件与元数据不匹配".to_string()); - } - Ok((metadata, bytes)) -} - -fn read_staged_image_metadata_locked( - root: &Path, - token: &str, -) -> Result, String> { - validate_plain_component(token, "stagedImageToken", 128)?; - let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), - "素材画布 staging 元数据", - 16 * 1024, - )?; - if let Some(metadata) = metadata.as_ref() { - if metadata.staged_image_token != token - || metadata.schema_version != "game-creator-asset-canvas-staging.v1" - { - return Err("素材画布 staging 身份无效".to_string()); - } - } - Ok(metadata) -} - -fn committed_result_from_ledger( - root: &Path, - ledger: &AssetCanvasCommitLedger, - manifest: GameCreationAppManifest, - project_revision: u64, -) -> Result { - let asset_id = ledger - .asset_id - .as_deref() - .ok_or_else(|| "已提交 ledger 缺少 assetId".to_string())?; - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == asset_id) - .cloned() - .ok_or_else(|| "已提交资产不在当前 manifest 中".to_string())?; - let committed_project_revision = ledger - .committed_project_revision - .ok_or_else(|| "已提交 ledger 缺少 committedProjectRevision".to_string())?; - let draft_revision = ledger - .committed_draft_revision - .ok_or_else(|| "已提交 ledger 缺少 committedDraftRevision".to_string())?; - let event_body = ledger - .event_payload - .clone() - .ok_or_else(|| "已提交 ledger 缺少事件 payload".to_string())?; - Ok(CommitAssetCanvasExecution { - result: CommitAssetCanvasResult::AlreadyCommitted { - project_id: ledger.project_id.clone(), - project_revision, - committed_project_revision, - draft_id: ledger.draft_id.clone(), - draft_revision, - commit_id: ledger.commit_id.clone(), - idempotency_key: ledger.idempotency_key.clone(), - event_id: ledger.event_id.clone(), - asset, - manifest, - }, - event: Some(event_body.with_project_path(root)), - }) -} - -fn asset_canvas_conflict( - input: &CommitAssetCanvasInput, - kind: AssetCanvasConflictKind, - project_id: Option, - project_revision: Option, - draft_revision: Option, - manifest: Option, -) -> CommitAssetCanvasExecution { - CommitAssetCanvasExecution { - result: CommitAssetCanvasResult::Conflict { - conflict_kind: kind, - expected_project_id: input.expected_project_id.clone(), - project_id, - expected_revision: input.expected_revision, - project_revision, - expected_draft_revision: input.expected_draft_revision, - draft_revision, - commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), - asset: None, - manifest, - }, - event: None, - } -} - -fn replace_asset_canvas_runtime_entry( - path: &Path, - bytes: &[u8], - label: &str, -) -> Result<(), String> { - let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; - if let Ok(metadata) = fs::symlink_metadata(path) { - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!("{label} 必须是普通文件")); - } - } - let temp_path = path.with_file_name(format!(".asset-canvas-runtime-{}.tmp", Uuid::new_v4())); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags(libc::O_NOFOLLOW); - } - let mut file = options - .open(&temp_path) - .map_err(|_| format!("创建 {label} 临时文件失败"))?; - if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { - let _ = fs::remove_file(&temp_path); - return Err(format!("写入 {label} 临时文件失败:{error}")); - } - drop(file); - fs::rename(&temp_path, path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!("安装 {label} 失败:{error}") - })?; - let (_, metadata) = open_project_snapshot_regular_file(path, label)?; - if metadata.len() != bytes.len() as u64 { - return Err(format!("{label} 安装后大小不一致")); - } - #[cfg(unix)] - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; - Ok(()) -} - -fn asset_canvas_runtime_entry_path( - root: &Path, - manifest_before: &GameCreationAppManifest, - asset_id: &str, -) -> Result, String> { - let mut historical = None; - for journal in read_asset_canvas_transaction_journals(root)? { - if journal.asset_id != asset_id { - continue; - } - let candidate_path = match journal.runtime_entry_relative_path.clone() { - Some(path) => Some(path), - None => { - let journal_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot( - root, - &journal.commit_id, - "manifest.before.json", - &journal.manifest_before_sha256, - )?; - journal_manifest_before - .assets - .iter() - .find(|asset| asset.id == asset_id) - .map(|asset| asset.local_path.clone()) - .filter(|path| !path.starts_with("assets/canvas/")) - } - }; - let Some(path) = candidate_path else { - continue; - }; - let is_newer = historical - .as_ref() - .is_none_or(|(_, revision): &(String, u64)| { - journal.target_project_revision > *revision - }); - if is_newer { - historical = Some((path, journal.target_project_revision)); - } - } - if let Some((path, _)) = historical { - return Ok(Some(path)); - } - - let Some(source) = manifest_before - .assets - .iter() - .find(|asset| asset.id == asset_id) - else { - return Ok(None); - }; - if source.local_path.starts_with("assets/canvas/") { - return Ok(None); - } - resolve_local_project_path(root, &source.local_path)?; - Ok(Some(source.local_path.clone())) -} - -fn runtime_entry_matches_journal( - root: &Path, - journal: &AssetCanvasTransactionJournal, -) -> Result { - let Some(relative) = journal.runtime_entry_relative_path.as_deref() else { - return Ok(true); - }; - let path = resolve_local_project_path(root, relative)?; - if !path.is_file() { - return Ok(false); - } - Ok(open_and_validate_image_file( - &path, - &journal.staged_image.media_type, - Some(&journal.final_image_sha256), - ) - .is_ok_and(|(bytes, width, height)| { - bytes.len() as u64 == journal.staged_image.byte_length - && width == journal.staged_image.pixel_width - && height == journal.staged_image.pixel_height - })) -} - -fn refresh_runtime_entry_for_journal( - root: &Path, - journal: &mut AssetCanvasTransactionJournal, -) -> Result<(), String> { - let Some(relative) = journal.runtime_entry_relative_path.as_deref() else { - return Ok(()); - }; - if runtime_entry_matches_journal(root, journal)? { - if journal.runtime_entry_refreshed { - return Ok(()); - } - journal.runtime_entry_refreshed = true; - journal.updated_at = asset_canvas_now(); - return write_asset_canvas_journal(root, journal); - } - let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; - let (bytes, _, _) = open_and_validate_image_file( - &final_path, - &journal.staged_image.media_type, - Some(&journal.final_image_sha256), - )?; - let runtime_path = resolve_local_project_path(root, relative)?; - replace_asset_canvas_runtime_entry(&runtime_path, &bytes, "素材运行入口")?; - journal.runtime_entry_refreshed = true; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, journal) -} - -fn verify_committed_asset_canvas_state( - root: &Path, - journal: &AssetCanvasTransactionJournal, - expected_manifest: &GameCreationAppManifest, - expected_revision: &AgentRuntimeProjectRevision, -) -> Result { - let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; - let (bytes, width, height) = open_and_validate_image_file( - &final_path, - &journal.staged_image.media_type, - Some(&journal.final_image_sha256), - )?; - if bytes.len() as u64 != journal.staged_image.byte_length - || width != journal.staged_image.pixel_width - || height != journal.staged_image.pixel_height - { - return Err("正式素材文件与 staging 身份不一致".to_string()); - } - let manifest = current_asset_canvas_manifest(root)?; - if manifest != *expected_manifest - || asset_canvas_sha256(&asset_canvas_json_bytes(&manifest)?) - != journal.manifest_after_sha256 - { - return Err("manifest 安装后回读不一致".to_string()); - } - let revision = read_game_creator_agent_runtime_project_revision(root)?; - if revision != *expected_revision - || revision.revision != journal.target_project_revision - || asset_canvas_sha256(&asset_canvas_json_bytes(&revision)?) - != journal.project_revision_after_sha256 - { - return Err("项目 revision 安装后回读不一致".to_string()); - } - let mut assets = manifest - .assets - .iter() - .filter(|asset| asset.id == journal.asset_id); - let asset = assets - .next() - .cloned() - .ok_or_else(|| "正式资产未进入 manifest".to_string())?; - let source_resource_id = asset_canvas_source_resource_id(&asset); - if assets.next().is_some() - || asset.local_path != journal.final_image_relative_path - || asset.media_type != journal.staged_image.media_type - || asset.source.resource_id.as_deref() != Some(source_resource_id.as_str()) - { - return Err("正式资产 manifest 身份无效".to_string()); - } - Ok(asset) -} - -fn write_asset_canvas_transaction_snapshot( - root: &Path, - commit_id: &str, - file_name: &str, - bytes: &[u8], -) -> Result<(), String> { - if bytes.len() > ASSET_CANVAS_MAX_LEDGER_BYTES { - return Err("素材画布事务快照超限".to_string()); - } - let path = resolve_local_project_path( - root, - &asset_canvas_transaction_relative_path(commit_id, file_name), - )?; - install_new_asset_canvas_file(&path, bytes, "素材画布事务快照") -} - -fn validate_generation_provenance(value: &AssetCanvasGenerationProvenance) -> Result<(), String> { - for (label, field, max_chars) in [ - ("taskId", value.task_id.as_str(), 512usize), - ("prompt", value.prompt.as_str(), 32_000usize), - ("model", value.model.as_str(), 512usize), - ( - "generationRoute", - value.generation_route.as_str(), - 1024usize, - ), - ("generationKind", value.generation_kind.as_str(), 128usize), - ] { - if field.trim().is_empty() - || field.chars().count() > max_chars - || field.chars().any(char::is_control) - { - return Err(format!("generationProvenance.{label} 无效")); - } - } - Ok(()) -} - -#[cfg(test)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AssetCanvasCommitFaultStage { - FirstSnapshotInstalled, - SnapshotsInstalled, - JournalInstalled, - Prepared, - FileInstalled, - ManifestInstalled, - RevisionInstalled, - Verified, - LedgerCommitted, -} - -#[cfg(not(test))] -type AssetCanvasCommitFaultStage = (); - -fn maybe_fail_asset_canvas_commit( - fault: Option, - #[allow(unused_variables)] stage: &str, -) -> Result<(), String> { - #[cfg(test)] - if fault.is_some_and(|fault| { - matches!( - (fault, stage), - ( - AssetCanvasCommitFaultStage::FirstSnapshotInstalled, - "first-snapshot-installed" - ) | ( - AssetCanvasCommitFaultStage::SnapshotsInstalled, - "snapshots-installed" - ) | ( - AssetCanvasCommitFaultStage::JournalInstalled, - "journal-installed" - ) | (AssetCanvasCommitFaultStage::Prepared, "prepared") - | (AssetCanvasCommitFaultStage::FileInstalled, "file-installed") - | ( - AssetCanvasCommitFaultStage::ManifestInstalled, - "manifest-installed" - ) - | ( - AssetCanvasCommitFaultStage::RevisionInstalled, - "revision-installed" - ) - | (AssetCanvasCommitFaultStage::Verified, "verified") - | ( - AssetCanvasCommitFaultStage::LedgerCommitted, - "ledger-committed" - ) - ) - }) { - return Err(format!("fault-injected:{stage}")); - } - Ok(()) -} - -fn commit_asset_canvas_at_internal( - root: &Path, - input: &CommitAssetCanvasInput, - fault: Option, -) -> Result { - validate_uuid_v4(&input.draft_id, "draftId")?; - validate_uuid_v4(&input.commit_id, "commitId")?; - validate_uuid_v4(&input.idempotency_key, "idempotencyKey")?; - validate_safe_revision(input.expected_revision, "expectedRevision")?; - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - let target_project_revision = input - .expected_revision - .checked_add(1) - .ok_or_else(|| "项目 revision 已达到上限".to_string())?; - let target_draft_revision = input - .expected_draft_revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(target_project_revision, "目标项目 revision")?; - validate_safe_revision(target_draft_revision, "目标草稿 revision")?; - let name = validate_asset_canvas_name(&input.name)?; - let asset_kind = validate_asset_kind(&input.asset_kind)?; - let mut references = normalize_asset_canvas_references(&input.reference_resource_ids)?; - match ( - input.source_layer_id.as_deref(), - input.media_sha256.as_deref(), - ) { - (Some(source_layer_id), Some(media_sha256)) => { - validate_plain_component(source_layer_id, "sourceLayerId", 512)?; - validate_asset_canvas_sha256(media_sha256, "mediaSha256")?; - } - (None, None) => {} - _ => return Err("sourceLayerId 与 mediaSha256 必须一起提供以冻结候选身份".to_string()), - } - if let Some(provenance) = input.generation_provenance.as_ref() { - validate_generation_provenance(provenance)?; - } - let preflight_manifest = - match validate_asset_canvas_project_identity(root, &input.expected_project_id) { - Ok(manifest) => manifest, - Err(error) if error.contains("expectedProjectId") => { - return Ok(asset_canvas_conflict( - input, - AssetCanvasConflictKind::ProjectIdentity, - None, - None, - None, - None, - )); - } - Err(error) => return Err(error), - }; - let preflight_revision = read_game_creator_agent_runtime_project_revision(root)?; - validate_safe_revision(preflight_revision.revision, "项目 revision")?; - - let _project_lock = acquire_project_write_lock(root, "asset-canvas.commit")?; - let _draft_lock = acquire_asset_canvas_draft_lock(root)?; - let mut manifest = - match validate_asset_canvas_project_identity(root, &input.expected_project_id) { - Ok(manifest) => manifest, - Err(error) if error.contains("expectedProjectId") => { - return Ok(asset_canvas_conflict( - input, - AssetCanvasConflictKind::ProjectIdentity, - None, - None, - None, - None, - )); - } - Err(error) => return Err(error), - }; - if preflight_manifest.project_id != manifest.project_id { - return Ok(asset_canvas_conflict( - input, - AssetCanvasConflictKind::ProjectIdentity, - None, - None, - None, - None, - )); - } - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - validate_safe_revision(current_revision.revision, "项目 revision")?; - let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - - if input.intent == AssetCanvasIntent::Refine { - let source_resource_id = draft - .source_resource_id - .as_deref() - .ok_or_else(|| "refine 草稿缺少源资源身份".to_string())?; - references.push(source_resource_id.to_string()); - references = normalize_asset_canvas_references(&references)?; - } - - let normalized_request = NormalizedAssetCanvasCommitRequest { - expected_project_id: input.expected_project_id.trim(), - expected_revision: input.expected_revision, - expected_draft_revision: input.expected_draft_revision, - draft_id: &input.draft_id, - commit_id: &input.commit_id, - idempotency_key: &input.idempotency_key, - intent: &input.intent, - source_asset_id: &input.source_asset_id, - staged_image_token: &input.staged_image_token, - source_layer_id: &input.source_layer_id, - media_sha256: &input.media_sha256, - name: &name, - asset_kind: &asset_kind, - reference_resource_ids: &references, - generation_provenance: &input.generation_provenance, - }; - let request_fingerprint = asset_canvas_sha256( - &serde_json::to_vec(&normalized_request) - .map_err(|error| format!("生成提交指纹失败:{error}"))?, - ); - let by_commit = read_asset_canvas_ledger(root, &input.commit_id)?; - let by_key = find_ledger_by_idempotency_key(root, &input.idempotency_key)?; - for ledger in [by_commit.as_ref(), by_key.as_ref()].into_iter().flatten() { - if ledger.commit_id != input.commit_id - || ledger.idempotency_key != input.idempotency_key - || ledger.request_fingerprint != request_fingerprint - { - return Err("commitId 或 idempotencyKey 已绑定到不同提交请求".to_string()); - } - if ledger.status == AssetCanvasLedgerStatus::Committed { - if let Some(mut journal) = read_asset_canvas_journal(root, &input.commit_id)? { - refresh_runtime_entry_for_journal(root, &mut journal)?; - } - return committed_result_from_ledger(root, ledger, manifest, current_revision.revision); - } - if matches!( - ledger.status, - AssetCanvasLedgerStatus::Prepared | AssetCanvasLedgerStatus::ReconciliationRequired - ) { - return Err("素材画布存在未完成事务,必须先恢复或对账".to_string()); - } - } - if current_revision.revision != input.expected_revision { - return Ok(asset_canvas_conflict( - input, - AssetCanvasConflictKind::ProjectRevision, - Some(manifest.project_id.clone()), - Some(current_revision.revision), - Some(draft.revision), - Some(manifest), - )); - } - if draft.revision != input.expected_draft_revision { - return Ok(asset_canvas_conflict( - input, - AssetCanvasConflictKind::DraftRevision, - Some(manifest.project_id.clone()), - Some(current_revision.revision), - Some(draft.revision), - Some(manifest), - )); - } - if preflight_revision.revision != input.expected_revision - && current_revision.revision == input.expected_revision - { - return Err("项目 revision 在提交预检期间发生身份替换".to_string()); - } - if draft.intent != input.intent || draft.source_asset_id != input.source_asset_id { - return Err("提交 intent/sourceAssetId 与权威草稿不一致".to_string()); - } - if !matches!( - draft.status, - AssetCanvasDraftStatus::Editing | AssetCanvasDraftStatus::Generating - ) { - return Err("素材画布草稿当前状态不允许提交".to_string()); - } - let (staged, staged_bytes) = read_staged_image_locked(root, &input.staged_image_token)?; - if staged.project_id != manifest.project_id - || staged.draft_id != draft.draft_id - || staged.draft_revision != draft.revision - { - return Err("staging 图片与当前项目草稿 revision 不匹配".to_string()); - } - let (committed_source_layer_id, committed_media_sha256) = match ( - input.source_layer_id.as_deref(), - input.media_sha256.as_deref(), - ) { - (Some(source_layer_id), Some(media_sha256)) => { - if staged.sha256 != media_sha256 { - return Err("候选媒体摘要与 staging 图片不一致".to_string()); - } - let layer = draft - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == source_layer_id) - .ok_or_else(|| "候选图层不属于当前草稿".to_string())?; - match &layer.media_ref { - AssetCanvasMediaRef::DraftMedia { - media_type, sha256, .. - } if media_type == "image/png" && sha256 == media_sha256 => {} - AssetCanvasMediaRef::DraftMedia { .. } => { - return Err("候选图层媒体身份已变化".to_string()) - } - AssetCanvasMediaRef::ProjectAsset { .. } => { - return Err("精修最终图片必须来自草稿候选媒体".to_string()) - } - } - (source_layer_id.to_string(), media_sha256.to_string()) - } - (None, None) => ("canvas-composite".to_string(), staged.sha256.clone()), - _ => unreachable!("候选身份配对已在提交预检中验证"), - }; - - let manifest_before = manifest.clone(); - if input.intent == AssetCanvasIntent::Refine { - let source_id = input - .source_asset_id - .as_deref() - .ok_or_else(|| "refine 提交缺少 sourceAssetId".to_string())?; - let source = manifest - .assets - .iter_mut() - .find(|asset| asset.id == source_id) - .ok_or_else(|| "refine 源资产不存在".to_string())?; - if !matches!( - source.media_type.as_str(), - "image/png" | "image/jpeg" | "image/webp" - ) { - return Err("refine 源资产不是受支持图片".to_string()); - } - let source_resource_id = asset_canvas_source_resource_id(source); - if draft.source_resource_id.as_deref() != Some(source_resource_id.as_str()) { - return Err("refine 源资源身份与草稿不一致".to_string()); - } - if source - .source - .resource_id - .as_deref() - .map(str::trim) - .is_some_and(|value| value != source_resource_id) - { - return Err("refine 源资源存在冲突 resourceId".to_string()); - } - source.source.resource_id = Some(source_resource_id.clone()); - references.push(source_resource_id); - references = normalize_asset_canvas_references(&references)?; - } else if input.source_asset_id.is_some() { - return Err("create 提交不能包含 sourceAssetId".to_string()); - } - - if input.intent == AssetCanvasIntent::Refine && staged.media_type != "image/png" { - return Err("精修最终图片必须是 PNG".to_string()); - } - let asset_id = input - .source_asset_id - .clone() - .unwrap_or_else(|| format!("canvas-{}", input.commit_id)); - ensure_no_unresolved_asset_canvas_commit_for_asset( - root, - &manifest.project_id, - &draft.draft_id, - &asset_id, - &input.commit_id, - )?; - if input.intent == AssetCanvasIntent::Create - && manifest.assets.iter().any(|asset| asset.id == asset_id) - { - return Err("目标 canvas asset 已存在但没有匹配 committed ledger".to_string()); - } - let extension = media_extension(&staged.media_type)?; - let final_relative_path = format!("assets/canvas/{name}--{}.{}", input.commit_id, extension); - let final_path = resolve_local_project_path(root, &final_relative_path)?; - if fs::symlink_metadata(&final_path).is_ok() { - return Err("正式素材目标路径已存在".to_string()); - } - let runtime_entry_relative_path = if input.intent == AssetCanvasIntent::Refine { - asset_canvas_runtime_entry_path(root, &manifest_before, &asset_id)? - } else { - None - }; - let asset = if input.intent == AssetCanvasIntent::Refine { - let source = manifest - .assets - .iter_mut() - .find(|asset| asset.id == asset_id) - .ok_or_else(|| "refine 源资产不存在".to_string())?; - source.media_type = "image/png".to_string(); - source.local_path = final_relative_path.clone(); - source.clone() - } else { - let source = GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: None, - resource_id: Some(format!("local-asset:{asset_id}")), - asset_object_id: None, - task_id: input - .generation_provenance - .as_ref() - .map(|value| value.task_id.clone()), - prompt: input - .generation_provenance - .as_ref() - .map(|value| value.prompt.clone()), - model: input - .generation_provenance - .as_ref() - .map(|value| value.model.clone()), - generation_route: input - .generation_provenance - .as_ref() - .map(|value| value.generation_route.clone()), - generation_kind: input - .generation_provenance - .as_ref() - .map(|value| value.generation_kind.clone()), - reference_resource_ids: references, - }; - let asset = GameCreationAppAssetManifestEntry { - id: asset_id.clone(), - kind: asset_kind, - media_type: staged.media_type.clone(), - local_path: final_relative_path.clone(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source, - }; - manifest.assets.push(asset.clone()); - asset - }; - - let mut target_revision_state = current_revision.clone(); - target_revision_state.revision = target_project_revision; - target_revision_state.updated_at = asset_canvas_now(); - let manifest_before_bytes = asset_canvas_json_bytes(&manifest_before)?; - let manifest_after_bytes = asset_canvas_json_bytes(&manifest)?; - let revision_before_bytes = asset_canvas_json_bytes(¤t_revision)?; - let revision_after_bytes = asset_canvas_json_bytes(&target_revision_state)?; - let occurred_at = asset_canvas_now(); - let event_id = Uuid::new_v4().to_string(); - let event_body = AssetCanvasCommittedEventBody { - schema_version: ASSET_CANVAS_EVENT_SCHEMA_VERSION.to_string(), - event_id: event_id.clone(), - project_id: manifest.project_id.clone(), - committed_project_revision: target_project_revision, - draft_id: draft.draft_id.clone(), - commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), - asset: asset.clone(), - manifest: manifest.clone(), - occurred_at, - }; - let mut ledger = AssetCanvasCommitLedger { - schema_version: ASSET_CANVAS_COMMIT_SCHEMA_VERSION.to_string(), - project_id: manifest.project_id.clone(), - draft_id: draft.draft_id.clone(), - commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), - request_fingerprint: request_fingerprint.clone(), - status: AssetCanvasLedgerStatus::Prepared, - expected_project_revision: input.expected_revision, - committed_project_revision: None, - expected_draft_revision: input.expected_draft_revision, - committed_draft_revision: None, - source_layer_id: committed_source_layer_id.clone(), - media_sha256: committed_media_sha256.clone(), - asset_id: None, - event_id: event_id.clone(), - event_payload: None, - event_delivery: AssetCanvasEventDelivery::Pending, - created_at: occurred_at, - updated_at: occurred_at, - }; - let mut journal = AssetCanvasTransactionJournal { - schema_version: ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION.to_string(), - project_id: manifest.project_id.clone(), - draft_id: draft.draft_id.clone(), - commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), - request_fingerprint, - stage: AssetCanvasTransactionStage::Prepared, - expected_project_revision: input.expected_revision, - target_project_revision, - expected_draft_revision: input.expected_draft_revision, - target_draft_revision, - staged_image: staged.clone(), - source_layer_id: committed_source_layer_id.clone(), - media_sha256: committed_media_sha256.clone(), - final_image_relative_path: final_relative_path.clone(), - final_image_sha256: staged.sha256.clone(), - final_image_existed_before: false, - runtime_entry_relative_path: runtime_entry_relative_path.clone(), - runtime_entry_refreshed: false, - manifest_before_sha256: asset_canvas_sha256(&manifest_before_bytes), - manifest_after_sha256: asset_canvas_sha256(&manifest_after_bytes), - project_revision_before_sha256: Some(asset_canvas_sha256(&revision_before_bytes)), - project_revision_after_sha256: asset_canvas_sha256(&revision_after_bytes), - asset_id: asset_id.clone(), - event_id: event_id.clone(), - occurred_at, - created_at: occurred_at, - updated_at: occurred_at, - }; - write_asset_canvas_transaction_snapshot( - root, - &input.commit_id, - "manifest.before.json", - &manifest_before_bytes, - )?; - maybe_fail_asset_canvas_commit(fault, "first-snapshot-installed")?; - write_asset_canvas_transaction_snapshot( - root, - &input.commit_id, - "manifest.after.json", - &manifest_after_bytes, - )?; - write_asset_canvas_transaction_snapshot( - root, - &input.commit_id, - "project-revision.before.json", - &revision_before_bytes, - )?; - write_asset_canvas_transaction_snapshot( - root, - &input.commit_id, - "project-revision.after.json", - &revision_after_bytes, - )?; - maybe_fail_asset_canvas_commit(fault, "snapshots-installed")?; - write_asset_canvas_journal(root, &journal)?; - maybe_fail_asset_canvas_commit(fault, "journal-installed")?; - write_asset_canvas_ledger(root, &ledger)?; - maybe_fail_asset_canvas_commit(fault, "prepared")?; - - install_new_asset_canvas_file(&final_path, &staged_bytes, "正式素材图片")?; - journal.stage = AssetCanvasTransactionStage::FileInstalled; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - maybe_fail_asset_canvas_commit(fault, "file-installed")?; - - let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; - write_manifest(&manifest_path, &manifest)?; - journal.stage = AssetCanvasTransactionStage::ManifestInstalled; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - maybe_fail_asset_canvas_commit(fault, "manifest-installed")?; - - write_game_creator_agent_runtime_project_revision(root, &target_revision_state)?; - journal.stage = AssetCanvasTransactionStage::RevisionInstalled; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - maybe_fail_asset_canvas_commit(fault, "revision-installed")?; - - verify_committed_asset_canvas_state(root, &journal, &manifest, &target_revision_state)?; - journal.stage = AssetCanvasTransactionStage::Verified; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - maybe_fail_asset_canvas_commit(fault, "verified")?; - - ledger.status = AssetCanvasLedgerStatus::Committed; - ledger.committed_project_revision = Some(target_project_revision); - ledger.committed_draft_revision = Some(target_draft_revision); - ledger.asset_id = Some(asset_id.clone()); - ledger.event_payload = Some(event_body.clone()); - ledger.updated_at = asset_canvas_now(); - write_asset_canvas_ledger(root, &ledger)?; - maybe_fail_asset_canvas_commit(fault, "ledger-committed")?; - - draft.revision = target_draft_revision; - draft.status = AssetCanvasDraftStatus::Editing; - draft.pending_commit = None; - draft.last_commit = Some(AssetCanvasLastCommit { - commit_id: input.commit_id.clone(), - idempotency_key: None, - asset_id: asset_id.clone(), - event_id: event_id.clone(), - committed_project_revision: target_project_revision, - source_layer_id: committed_source_layer_id, - media_sha256: committed_media_sha256, - }); - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - journal.stage = AssetCanvasTransactionStage::Committed; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - refresh_runtime_entry_for_journal(root, &mut journal)?; - - Ok(CommitAssetCanvasExecution { - result: CommitAssetCanvasResult::Committed { - project_id: manifest.project_id.clone(), - project_revision: target_project_revision, - committed_project_revision: target_project_revision, - draft_id: draft.draft_id, - draft_revision: target_draft_revision, - commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), - event_id: event_id.clone(), - asset, - manifest, - }, - event: Some(event_body.with_project_path(root)), - }) -} - -pub(crate) fn commit_asset_canvas_at( - root: &Path, - input: &CommitAssetCanvasInput, -) -> Result { - commit_asset_canvas_at_internal(root, input, None) -} - -pub(crate) fn commit_asset_canvas_candidate_at( - root: &Path, - input: &CommitAssetCanvasCandidateInput, -) -> Result { - let _name = validate_asset_canvas_name(&input.name)?; - let _asset_kind = validate_asset_kind(&input.asset_kind)?; - validate_plain_component(&input.source_layer_id, "sourceLayerId", 512)?; - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - - let (media_ref, media_sha256) = { - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.intent != AssetCanvasIntent::Refine - || draft.source_asset_id != input.source_asset_id - { - return Err("候选图提交身份与精修草稿不一致".to_string()); - } - if draft.revision != input.expected_draft_revision { - return Err("候选图提交时草稿 revision 已变化".to_string()); - } - let layer = draft - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == input.source_layer_id) - .ok_or_else(|| "候选图层不属于当前草稿".to_string())?; - match &layer.media_ref { - AssetCanvasMediaRef::DraftMedia { - media_type, sha256, .. - } if media_type == "image/png" => (layer.media_ref.clone(), sha256.clone()), - AssetCanvasMediaRef::DraftMedia { .. } => { - return Err("精修最终图片必须是 PNG".to_string()); - } - AssetCanvasMediaRef::ProjectAsset { .. } => { - return Err("精修最终图片必须来自草稿候选媒体".to_string()); - } - } - }; - - let media = read_asset_canvas_media_at( - root, - &ReadAssetCanvasMediaInput { - project_path: input.project_path.clone(), - expected_project_id: input.expected_project_id.clone(), - draft_id: input.draft_id.clone(), - media_ref, - }, - )?; - if media.media_type != "image/png" { - return Err("精修最终图片必须是 PNG".to_string()); - } - let staged = stage_asset_canvas_image_at( - root, - &StageAssetCanvasImageInput { - project_path: input.project_path.clone(), - expected_project_id: input.expected_project_id.clone(), - draft_id: input.draft_id.clone(), - expected_draft_revision: input.expected_draft_revision, - media_type: "image/png".to_string(), - bytes: media.bytes, - }, - )?; - if staged.status != "staged" { - return Err("候选图提交时草稿 revision 已变化".to_string()); - } - let staged_image_token = staged - .staged_image_token - .ok_or_else(|| "候选图 staging token 缺失".to_string())?; - - commit_asset_canvas_at( - root, - &CommitAssetCanvasInput { - project_path: input.project_path.clone(), - expected_project_id: input.expected_project_id.clone(), - expected_revision: input.expected_revision, - expected_draft_revision: input.expected_draft_revision, - draft_id: input.draft_id.clone(), - commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), - intent: AssetCanvasIntent::Refine, - source_asset_id: input.source_asset_id.clone(), - staged_image_token, - source_layer_id: Some(input.source_layer_id.clone()), - media_sha256: Some(media_sha256), - name: input.name.clone(), - asset_kind: input.asset_kind.clone(), - reference_resource_ids: input.reference_resource_ids.clone(), - generation_provenance: None, - }, - ) -} - -pub(crate) fn mark_asset_canvas_event_attempted_at( - root: &Path, - project_id: &str, - commit_id: &str, -) -> Result<(), String> { - validate_asset_canvas_project_identity(root, project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - validate_asset_canvas_project_identity(root, project_id)?; - let mut ledger = read_asset_canvas_ledger(root, commit_id)? - .ok_or_else(|| "素材画布 commit ledger 不存在".to_string())?; - if ledger.project_id != project_id || ledger.status != AssetCanvasLedgerStatus::Committed { - return Err("素材画布 commit ledger 尚未提交".to_string()); - } - ledger.event_delivery = AssetCanvasEventDelivery::Attempted; - ledger.updated_at = asset_canvas_now(); - write_asset_canvas_ledger(root, &ledger)?; - if let Some(mut journal) = read_asset_canvas_journal(root, commit_id)? { - if journal.stage == AssetCanvasTransactionStage::Committed { - journal.stage = AssetCanvasTransactionStage::EventAttempted; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - } - } - Ok(()) -} - -pub(crate) fn publish_asset_canvas_event_after_commit_at( - root: &Path, - event: &AssetCanvasCommittedEvent, - emit: F, -) -> bool -where - F: FnOnce(&AssetCanvasCommittedEvent) -> Result<(), String>, -{ - let emitted = emit(event).is_ok(); - let _ = mark_asset_canvas_event_attempted_at(root, &event.project_id, &event.commit_id); - emitted -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct RecoverAssetCanvasTransactionsInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum RecoverAssetCanvasOutcomeStatus { - Committed, - AlreadyCommitted, - RolledBack, - ReconciliationRequired, - Superseded, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RecoverAssetCanvasOutcome { - pub(crate) commit_id: String, - pub(crate) status: RecoverAssetCanvasOutcomeStatus, - pub(crate) event_id: Option, - pub(crate) asset_id: Option, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RecoverAssetCanvasTransactionsResult { - pub(crate) project_id: String, - pub(crate) project_revision: u64, - pub(crate) manifest: GameCreationAppManifest, - pub(crate) outcomes: Vec, -} - -#[derive(Debug)] -pub(crate) struct RecoverAssetCanvasExecution { - pub(crate) result: RecoverAssetCanvasTransactionsResult, - pub(crate) events: Vec, -} - -fn read_asset_canvas_snapshot( - root: &Path, - commit_id: &str, - file_name: &str, - expected_sha256: &str, -) -> Result { - let path = resolve_local_project_path( - root, - &asset_canvas_transaction_relative_path(commit_id, file_name), - )?; - let (mut file, metadata) = open_project_snapshot_regular_file(&path, "素材画布事务快照")?; - if metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 { - return Err("素材画布事务快照超限".to_string()); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take((ASSET_CANVAS_MAX_LEDGER_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|_| "读取素材画布事务快照失败".to_string())?; - if asset_canvas_sha256(&bytes) != expected_sha256 { - return Err("素材画布事务快照摘要不匹配".to_string()); - } - serde_json::from_slice(&bytes).map_err(|_| "解析素材画布事务快照失败".to_string()) -} - -fn current_manifest_sha(root: &Path) -> Result<(GameCreationAppManifest, String), String> { - let manifest = current_asset_canvas_manifest(root)?; - let sha = asset_canvas_sha256(&asset_canvas_json_bytes(&manifest)?); - Ok((manifest, sha)) -} - -fn current_revision_sha(root: &Path) -> Result<(AgentRuntimeProjectRevision, String), String> { - let revision = read_game_creator_agent_runtime_project_revision(root)?; - let sha = asset_canvas_sha256(&asset_canvas_json_bytes(&revision)?); - Ok((revision, sha)) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AssetCanvasFinalImageState { - Absent, - Matches, - Mismatch, -} - -fn final_image_state_for_journal( - root: &Path, - journal: &AssetCanvasTransactionJournal, -) -> Result { - let path = resolve_local_project_path(root, &journal.final_image_relative_path)?; - match fs::symlink_metadata(&path) { - Ok(_) => Ok( - if open_and_validate_image_file( - &path, - &journal.staged_image.media_type, - Some(&journal.final_image_sha256), - ) - .is_ok_and(|(bytes, width, height)| { - bytes.len() as u64 == journal.staged_image.byte_length - && width == journal.staged_image.pixel_width - && height == journal.staged_image.pixel_height - }) { - AssetCanvasFinalImageState::Matches - } else { - AssetCanvasFinalImageState::Mismatch - }, - ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Ok(AssetCanvasFinalImageState::Absent) - } - Err(_) => Err("读取正式素材恢复目标失败".to_string()), - } -} - -fn find_superseding_asset_canvas_commit( - root: &Path, - journal: &AssetCanvasTransactionJournal, - current_manifest: &GameCreationAppManifest, - current_manifest_sha256: &str, - current_revision: &AgentRuntimeProjectRevision, - current_revision_sha256: &str, -) -> Result { - if final_image_state_for_journal(root, journal)? != AssetCanvasFinalImageState::Matches { - return Ok(false); - } - let journals = read_asset_canvas_transaction_journals(root)?; - let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot( - root, - &journal.commit_id, - "manifest.after.json", - &journal.manifest_after_sha256, - )?; - let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( - root, - &journal.commit_id, - "project-revision.after.json", - &journal.project_revision_after_sha256, - )?; - let mut pending = vec![(journal.clone(), manifest_after, revision_after)]; - let mut visited = HashSet::from([journal.commit_id.clone()]); - - while let Some((previous, previous_manifest_after, previous_revision_after)) = pending.pop() { - for later in &journals { - if visited.contains(&later.commit_id) - || later.project_id != previous.project_id - || later.draft_id != previous.draft_id - || later.asset_id != previous.asset_id - || later.expected_project_revision != previous.target_project_revision - || later.manifest_before_sha256 != previous.manifest_after_sha256 - || later.project_revision_before_sha256.as_deref() - != Some(previous.project_revision_after_sha256.as_str()) - || later.final_image_relative_path == previous.final_image_relative_path - { - continue; - } - let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else { - continue; - }; - let finalized_link = matches!( - later.stage, - AssetCanvasTransactionStage::Committed - | AssetCanvasTransactionStage::EventAttempted - ) && ledger.status == AssetCanvasLedgerStatus::Committed; - let superseded_link = later.stage == AssetCanvasTransactionStage::Superseded - && ledger.status == AssetCanvasLedgerStatus::Superseded; - if !asset_canvas_journal_ledger_identity_matches(later, &ledger) - || (!finalized_link && !superseded_link) - || ledger.committed_project_revision != Some(later.target_project_revision) - || ledger.committed_draft_revision != Some(later.target_draft_revision) - || ledger.asset_id.as_deref() != Some(later.asset_id.as_str()) - || ledger.event_payload.is_none() - { - continue; - } - - let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot( - root, - &later.commit_id, - "manifest.before.json", - &later.manifest_before_sha256, - )?; - let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( - root, - &later.commit_id, - "project-revision.before.json", - later - .project_revision_before_sha256 - .as_deref() - .ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?, - )?; - let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot( - root, - &later.commit_id, - "manifest.after.json", - &later.manifest_after_sha256, - )?; - let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( - root, - &later.commit_id, - "project-revision.after.json", - &later.project_revision_after_sha256, - )?; - if later_manifest_before != previous_manifest_after - || later_revision_before != previous_revision_after - || later_revision_after.revision != later.target_project_revision - || later.target_project_revision - != later.expected_project_revision.saturating_add(1) - || final_image_state_for_journal(root, later)? - != AssetCanvasFinalImageState::Matches - { - continue; - } - - let reaches_current_state = current_manifest == &later_manifest_after - && current_manifest_sha256 == later.manifest_after_sha256 - && current_revision == &later_revision_after - && current_revision_sha256 == later.project_revision_after_sha256 - && current_revision.revision == later.target_project_revision; - if reaches_current_state && finalized_link { - let matching_assets = current_manifest - .assets - .iter() - .filter(|asset| asset.id == later.asset_id) - .collect::>(); - if matching_assets.len() == 1 - && matching_assets[0].local_path == later.final_image_relative_path - && matching_assets[0].media_type == later.staged_image.media_type - { - return Ok(true); - } - continue; - } - - visited.insert(later.commit_id.clone()); - pending.push((later.clone(), later_manifest_after, later_revision_after)); - } - } - Ok(false) -} - -fn mark_asset_canvas_superseded_locked( - root: &Path, - mut journal: AssetCanvasTransactionJournal, - mut ledger: AssetCanvasCommitLedger, -) -> Result { - journal.stage = AssetCanvasTransactionStage::Superseded; - journal.updated_at = asset_canvas_now(); - ledger.status = AssetCanvasLedgerStatus::Superseded; - ledger.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - write_asset_canvas_ledger(root, &ledger)?; - Ok(RecoverAssetCanvasOutcome { - commit_id: journal.commit_id, - status: RecoverAssetCanvasOutcomeStatus::Superseded, - event_id: Some(journal.event_id), - asset_id: None, - }) -} - -fn mark_asset_canvas_reconciliation_locked( - root: &Path, - mut journal: AssetCanvasTransactionJournal, - mut ledger: AssetCanvasCommitLedger, -) -> Result { - journal.stage = AssetCanvasTransactionStage::ReconciliationRequired; - journal.updated_at = asset_canvas_now(); - ledger.status = AssetCanvasLedgerStatus::ReconciliationRequired; - ledger.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - write_asset_canvas_ledger(root, &ledger)?; - if let Some(mut draft) = - read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? - { - if draft.revision == journal.expected_draft_revision { - draft.revision = journal.target_draft_revision; - draft.status = AssetCanvasDraftStatus::ReconciliationRequired; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - } - } - Ok(RecoverAssetCanvasOutcome { - commit_id: journal.commit_id, - status: RecoverAssetCanvasOutcomeStatus::ReconciliationRequired, - event_id: Some(journal.event_id), - asset_id: None, - }) -} - -fn finish_recovered_asset_canvas_commit_locked( - root: &Path, - mut journal: AssetCanvasTransactionJournal, - mut ledger: AssetCanvasCommitLedger, - manifest_after: GameCreationAppManifest, - revision_after: AgentRuntimeProjectRevision, -) -> Result<(RecoverAssetCanvasOutcome, AssetCanvasCommittedEvent), String> { - let asset = - verify_committed_asset_canvas_state(root, &journal, &manifest_after, &revision_after)?; - let event_body = - ledger - .event_payload - .clone() - .unwrap_or_else(|| AssetCanvasCommittedEventBody { - schema_version: ASSET_CANVAS_EVENT_SCHEMA_VERSION.to_string(), - event_id: journal.event_id.clone(), - project_id: journal.project_id.clone(), - committed_project_revision: journal.target_project_revision, - draft_id: journal.draft_id.clone(), - commit_id: journal.commit_id.clone(), - idempotency_key: journal.idempotency_key.clone(), - asset: asset.clone(), - manifest: manifest_after.clone(), - occurred_at: journal.occurred_at, - }); - ledger.status = AssetCanvasLedgerStatus::Committed; - ledger.committed_project_revision = Some(journal.target_project_revision); - ledger.committed_draft_revision = Some(journal.target_draft_revision); - ledger.asset_id = Some(journal.asset_id.clone()); - ledger.event_payload = Some(event_body.clone()); - ledger.updated_at = asset_canvas_now(); - write_asset_canvas_ledger(root, &ledger)?; - let mut draft = read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? - .ok_or_else(|| "恢复提交时草稿不存在".to_string())?; - let already_matches = draft.last_commit.as_ref().is_some_and(|last| { - last.commit_id == journal.commit_id - && last.asset_id == journal.asset_id - && last.event_id == journal.event_id - && last.committed_project_revision == journal.target_project_revision - && last.source_layer_id == journal.source_layer_id - && last.media_sha256 == journal.media_sha256 - }); - let mut draft_changed = false; - if !already_matches { - if draft.revision != journal.expected_draft_revision { - return Err("恢复提交时草稿 revision 已被其它写入推进".to_string()); - } - draft.revision = journal.target_draft_revision; - draft.last_commit = Some(AssetCanvasLastCommit { - commit_id: journal.commit_id.clone(), - idempotency_key: None, - asset_id: journal.asset_id.clone(), - event_id: journal.event_id.clone(), - committed_project_revision: journal.target_project_revision, - source_layer_id: journal.source_layer_id.clone(), - media_sha256: journal.media_sha256.clone(), - }); - draft_changed = true; - } - if draft.status != AssetCanvasDraftStatus::Editing || draft.pending_commit.is_some() { - draft.status = AssetCanvasDraftStatus::Editing; - draft.pending_commit = None; - draft_changed = true; - } - if draft_changed { - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - } - refresh_runtime_entry_for_journal(root, &mut journal)?; - journal.stage = AssetCanvasTransactionStage::Committed; - journal.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &journal)?; - Ok(( - RecoverAssetCanvasOutcome { - commit_id: journal.commit_id.clone(), - status: RecoverAssetCanvasOutcomeStatus::Committed, - event_id: Some(journal.event_id.clone()), - asset_id: Some(journal.asset_id.clone()), - }, - event_body.with_project_path(root), - )) -} - -fn recover_asset_canvas_transaction_locked( - root: &Path, - mut journal: AssetCanvasTransactionJournal, -) -> Result<(RecoverAssetCanvasOutcome, Option), String> { - if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION { - return Err("不支持的素材画布 transaction schema".to_string()); - } - let mut ledger = read_asset_canvas_ledger(root, &journal.commit_id)? - .ok_or_else(|| "素材画布 transaction 缺少 commit ledger".to_string())?; - if ledger.schema_version != ASSET_CANVAS_COMMIT_SCHEMA_VERSION - || ledger.project_id != journal.project_id - || ledger.draft_id != journal.draft_id - || ledger.idempotency_key != journal.idempotency_key - || ledger.request_fingerprint != journal.request_fingerprint - || ledger.source_layer_id != journal.source_layer_id - || ledger.media_sha256 != journal.media_sha256 - { - return Err("素材画布 journal 与 ledger 身份不一致".to_string()); - } - let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot( - root, - &journal.commit_id, - "manifest.after.json", - &journal.manifest_after_sha256, - )?; - let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot( - root, - &journal.commit_id, - "project-revision.after.json", - &journal.project_revision_after_sha256, - )?; - let (current_manifest, current_manifest_sha) = current_manifest_sha(root)?; - let (current_revision, current_revision_sha) = current_revision_sha(root)?; - let final_image_state = final_image_state_for_journal(root, &journal)?; - let file_matches = final_image_state == AssetCanvasFinalImageState::Matches; - - if ledger.status == AssetCanvasLedgerStatus::Superseded - && journal.stage == AssetCanvasTransactionStage::Superseded - { - return Ok(( - RecoverAssetCanvasOutcome { - commit_id: journal.commit_id, - status: RecoverAssetCanvasOutcomeStatus::Superseded, - event_id: Some(journal.event_id), - asset_id: None, - }, - None, - )); - } - if ledger.status == AssetCanvasLedgerStatus::RolledBack - && journal.stage == AssetCanvasTransactionStage::RolledBack - { - return Ok(( - RecoverAssetCanvasOutcome { - commit_id: journal.commit_id, - status: RecoverAssetCanvasOutcomeStatus::RolledBack, - event_id: Some(journal.event_id), - asset_id: None, - }, - None, - )); - } - - if ledger.status == AssetCanvasLedgerStatus::Committed { - let asset_present = current_manifest.assets.iter().any(|asset| { - asset.id == journal.asset_id && asset.local_path == journal.final_image_relative_path - }); - if file_matches - && asset_present - && current_revision.revision >= journal.target_project_revision - { - refresh_runtime_entry_for_journal(root, &mut journal)?; - let mut draft = - read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? - .ok_or_else(|| "已提交事务缺少草稿".to_string())?; - let last_commit_matches = draft.last_commit.as_ref().is_some_and(|last| { - last.commit_id == journal.commit_id - && last.asset_id == journal.asset_id - && last.event_id == journal.event_id - && last.committed_project_revision == journal.target_project_revision - && last.source_layer_id == journal.source_layer_id - && last.media_sha256 == journal.media_sha256 - }); - let mut draft_changed = false; - if !last_commit_matches { - if draft.revision != journal.expected_draft_revision { - return mark_asset_canvas_reconciliation_locked(root, journal, ledger) - .map(|outcome| (outcome, None)); - } - draft.revision = journal.target_draft_revision; - draft.last_commit = Some(AssetCanvasLastCommit { - commit_id: journal.commit_id.clone(), - idempotency_key: None, - asset_id: journal.asset_id.clone(), - event_id: journal.event_id.clone(), - committed_project_revision: journal.target_project_revision, - source_layer_id: journal.source_layer_id.clone(), - media_sha256: journal.media_sha256.clone(), - }); - draft_changed = true; - } - if draft.status != AssetCanvasDraftStatus::Editing || draft.pending_commit.is_some() { - draft.status = AssetCanvasDraftStatus::Editing; - draft.pending_commit = None; - draft_changed = true; - } - if draft_changed { - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - } - let event = ledger - .event_payload - .as_ref() - .map(|payload| payload.with_project_path(root)); - return Ok(( - RecoverAssetCanvasOutcome { - commit_id: journal.commit_id, - status: RecoverAssetCanvasOutcomeStatus::AlreadyCommitted, - event_id: Some(journal.event_id), - asset_id: Some(journal.asset_id), - }, - event, - )); - } - if find_superseding_asset_canvas_commit( - root, - &journal, - ¤t_manifest, - ¤t_manifest_sha, - ¤t_revision, - ¤t_revision_sha, - )? { - return mark_asset_canvas_superseded_locked(root, journal, ledger) - .map(|outcome| (outcome, None)); - } - return mark_asset_canvas_reconciliation_locked(root, journal, ledger) - .map(|outcome| (outcome, None)); - } - - let manifest_before = current_manifest_sha == journal.manifest_before_sha256; - let manifest_is_after = current_manifest_sha == journal.manifest_after_sha256; - let revision_before = - journal.project_revision_before_sha256.as_deref() == Some(current_revision_sha.as_str()); - let revision_is_after = current_revision_sha == journal.project_revision_after_sha256; - if manifest_before && revision_before { - match final_image_state { - AssetCanvasFinalImageState::Matches => { - let path = resolve_local_project_path(root, &journal.final_image_relative_path)?; - fs::remove_file(&path).map_err(|_| "安全回滚当前事务新文件失败".to_string())?; - } - AssetCanvasFinalImageState::Absent => {} - AssetCanvasFinalImageState::Mismatch => { - return mark_asset_canvas_reconciliation_locked(root, journal, ledger) - .map(|outcome| (outcome, None)); - } - } - ledger.status = AssetCanvasLedgerStatus::RolledBack; - ledger.updated_at = asset_canvas_now(); - write_asset_canvas_ledger(root, &ledger)?; - let mut rolled_back = journal.clone(); - rolled_back.stage = AssetCanvasTransactionStage::RolledBack; - rolled_back.updated_at = asset_canvas_now(); - write_asset_canvas_journal(root, &rolled_back)?; - return Ok(( - RecoverAssetCanvasOutcome { - commit_id: journal.commit_id, - status: RecoverAssetCanvasOutcomeStatus::RolledBack, - event_id: Some(journal.event_id), - asset_id: None, - }, - None, - )); - } - if manifest_is_after && revision_before && file_matches { - write_game_creator_agent_runtime_project_revision(root, &revision_after)?; - return finish_recovered_asset_canvas_commit_locked( - root, - journal, - ledger, - manifest_after, - revision_after, - ) - .map(|(outcome, event)| (outcome, Some(event))); - } - if manifest_is_after && revision_is_after && file_matches { - return finish_recovered_asset_canvas_commit_locked( - root, - journal, - ledger, - manifest_after, - revision_after, - ) - .map(|(outcome, event)| (outcome, Some(event))); - } - let unresolved = matches!( - journal.stage, - AssetCanvasTransactionStage::Prepared - | AssetCanvasTransactionStage::FileInstalled - | AssetCanvasTransactionStage::ManifestInstalled - | AssetCanvasTransactionStage::RevisionInstalled - | AssetCanvasTransactionStage::Verified - | AssetCanvasTransactionStage::ReconciliationRequired - ) && matches!( - ledger.status, - AssetCanvasLedgerStatus::Prepared | AssetCanvasLedgerStatus::ReconciliationRequired - ); - if unresolved - && find_superseding_asset_canvas_commit( - root, - &journal, - ¤t_manifest, - ¤t_manifest_sha, - ¤t_revision, - ¤t_revision_sha, - )? - { - return mark_asset_canvas_superseded_locked(root, journal, ledger) - .map(|outcome| (outcome, None)); - } - mark_asset_canvas_reconciliation_locked(root, journal, ledger).map(|outcome| (outcome, None)) -} - -fn clean_unpublished_asset_canvas_transaction_locked( - root: &Path, - commit_id: &str, - journal: Option<&AssetCanvasTransactionJournal>, -) -> Result { - if let Some(journal) = journal { - if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION - || journal.commit_id != commit_id - || journal.stage != AssetCanvasTransactionStage::Prepared - { - return Err("缺少 ledger 的素材画布 transaction 身份无效".to_string()); - } - let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; - match fs::symlink_metadata(&final_path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Ok(_) => return Err("缺少 ledger 的素材画布 transaction 已产生正式文件".to_string()), - Err(_) => return Err("检查未发布素材画布 transaction 正式文件失败".to_string()), - } - let current_manifest = current_asset_canvas_manifest(root)?; - if asset_canvas_sha256(&asset_canvas_json_bytes(¤t_manifest)?) - != journal.manifest_before_sha256 - { - return Err("缺少 ledger 的素材画布 transaction manifest 已变化".to_string()); - } - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - if journal.project_revision_before_sha256.as_deref() - != Some(asset_canvas_sha256(&asset_canvas_json_bytes(¤t_revision)?).as_str()) - { - return Err("缺少 ledger 的素材画布 transaction revision 已变化".to_string()); - } - } - - let transaction_directory = resolve_local_project_path( - root, - &format!("{ASSET_CANVAS_ROOT}/transactions/{commit_id}"), - )?; - let mut files = Vec::new(); - for entry in fs::read_dir(&transaction_directory) - .map_err(|_| "读取未发布素材画布 transaction 失败".to_string())? - { - let entry = entry.map_err(|_| "读取未发布素材画布 transaction 条目失败".to_string())?; - let metadata = fs::symlink_metadata(entry.path()) - .map_err(|_| "读取未发布素材画布 transaction 元数据失败".to_string())?; - let name = entry.file_name().to_string_lossy().into_owned(); - let is_expected_snapshot = matches!( - name.as_str(), - "manifest.before.json" - | "manifest.after.json" - | "project-revision.before.json" - | "project-revision.after.json" - | "journal.json" - ); - let is_owned_temporary_file = (name.starts_with(".asset-canvas-") - && name.ends_with(".tmp")) - || name.starts_with(".journal.json.tmp."); - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 - || (!is_expected_snapshot && !is_owned_temporary_file) - { - return Err("未发布素材画布 transaction 包含未知文件".to_string()); - } - files.push(entry.path()); - } - if files.len() > 16 { - return Err("未发布素材画布 transaction 文件数量超限".to_string()); - } - for path in files { - fs::remove_file(path).map_err(|_| "清理未发布素材画布 transaction 文件失败".to_string())?; - } - fs::remove_dir(&transaction_directory) - .map_err(|_| "清理未发布素材画布 transaction 目录失败".to_string())?; - #[cfg(unix)] - if let Some(parent) = transaction_directory.parent() { - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| "同步素材画布 transaction 清理结果失败".to_string())?; - } - Ok(RecoverAssetCanvasOutcome { - commit_id: commit_id.to_string(), - status: RecoverAssetCanvasOutcomeStatus::RolledBack, - event_id: None, - asset_id: None, - }) -} - -pub(crate) fn recover_asset_canvas_transactions_at( - root: &Path, - expected_project_id: &str, -) -> Result { - validate_asset_canvas_project_identity(root, expected_project_id)?; - let _project_lock = acquire_project_write_lock(root, "asset-canvas.recover")?; - let _draft_lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; - let transactions = - resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/transactions"))?; - let mut commit_ids = Vec::new(); - match fs::read_dir(&transactions) { - Ok(entries) => { - for entry in entries { - let entry = entry.map_err(|_| "读取素材画布 transaction 目录失败".to_string())?; - let metadata = fs::symlink_metadata(entry.path()) - .map_err(|_| "读取素材画布 transaction 元数据失败".to_string())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("素材画布 transaction 必须是普通目录".to_string()); - } - let id = entry.file_name().to_string_lossy().into_owned(); - validate_uuid_v4(&id, "transaction commitId")?; - commit_ids.push(id); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err("读取素材画布 transaction 目录失败".to_string()), - } - commit_ids.sort(); - if commit_ids.len() > 4096 { - return Err("素材画布 transaction 数量超限".to_string()); - } - let mut outcomes = Vec::new(); - let mut events = Vec::new(); - for commit_id in commit_ids { - let journal = read_asset_canvas_journal(root, &commit_id)?; - if read_asset_canvas_ledger(root, &commit_id)?.is_none() { - outcomes.push(clean_unpublished_asset_canvas_transaction_locked( - root, - &commit_id, - journal.as_ref(), - )?); - continue; - } - let Some(journal) = journal else { - return Err("素材画布 transaction 缺少 journal".to_string()); - }; - let (outcome, event) = recover_asset_canvas_transaction_locked(root, journal)?; - outcomes.push(outcome); - if let Some(event) = event { - events.push(event); - } - } - let final_manifest = current_asset_canvas_manifest(root)?; - let revision = read_game_creator_agent_runtime_project_revision(root)?; - if final_manifest.project_id != manifest.project_id { - return Err("项目身份在 transaction 恢复期间发生变化".to_string()); - } - Ok(RecoverAssetCanvasExecution { - result: RecoverAssetCanvasTransactionsResult { - project_id: final_manifest.project_id.clone(), - project_revision: revision.revision, - manifest: final_manifest, - outcomes, - }, - events, - }) -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct DiscardAssetCanvasDraftInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) expected_draft_revision: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DiscardAssetCanvasDraftResult { - pub(crate) status: String, - pub(crate) draft: AssetCanvasDraft, -} - -pub(crate) fn discard_asset_canvas_draft_at( - root: &Path, - input: &DiscardAssetCanvasDraftInput, -) -> Result { - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let mut 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 Ok(DiscardAssetCanvasDraftResult { - status: "conflict".to_string(), - draft, - }); - } - if matches!( - draft.status, - AssetCanvasDraftStatus::CommitPrepared | AssetCanvasDraftStatus::ReconciliationRequired - ) { - return Ok(DiscardAssetCanvasDraftResult { - status: "commit-in-progress".to_string(), - draft, - }); - } - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.status = AssetCanvasDraftStatus::Cancelled; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - Ok(DiscardAssetCanvasDraftResult { - status: "cancelled".to_string(), - draft, - }) -} - -#[cfg(test)] -#[path = "asset_canvas_tests.rs"] -mod tests; 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 deleted file mode 100644 index 61f5f5306..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ /dev/null @@ -1,9514 +0,0 @@ -use super::*; -use reqwest::multipart::{Form, Part}; -use std::collections::{BTreeMap, HashMap}; - -const ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION: &str = - "game-creator-asset-canvas-generation.v1"; -pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str = - "game-creator-asset-generation-progress"; -const ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES: usize = 512 * 1024; -const ASSET_CANVAS_GENERATION_REFERENCE_LIMIT: usize = 9; -const ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS: u64 = 10 * 60 * 1_000; -const ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; -const ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; -const ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE: &str = "asset-canvas-references"; -const ASSET_CANVAS_PLATFORM_ACCESS_SCHEME: &str = "official-platform-v1"; -const ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME: &str = "external-developer-v1"; - -static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< - tokio::sync::Mutex>>>, -> = OnceLock::new(); - -#[cfg(test)] -#[derive(Clone)] -struct AssetCanvasAccountBoundWriteTestBarrier { - entered: Arc, - release: Arc, -} - -#[cfg(test)] -thread_local! { - static ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; -} - -#[cfg(test)] -fn install_asset_canvas_account_bound_write_test_barrier( - barrier: AssetCanvasAccountBoundWriteTestBarrier, -) { - ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| { - *slot.borrow_mut() = Some(barrier); - }); -} - -#[cfg(test)] -fn wait_asset_canvas_account_bound_write_test_barrier() { - let barrier = - ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| slot.borrow_mut().take()); - if let Some(barrier) = barrier { - barrier.entered.wait(); - barrier.release.wait(); - } -} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct GenerateAssetCanvasImageInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) expected_host_revision: u64, - pub(crate) expected_draft_revision: u64, - pub(crate) draft_id: String, - pub(crate) intent_id: String, - pub(crate) generation_id: String, - pub(crate) idempotency_key: String, - pub(crate) commit_id: String, - pub(crate) commit_idempotency_key: String, - pub(crate) prompt: String, - pub(crate) aspect_ratio: String, - pub(crate) image_size: String, - pub(crate) asset_kind: String, - pub(crate) asset_name: String, - pub(crate) reference_resource_ids: Vec, - #[serde(default)] - pub(crate) source_layer_id: Option, - #[serde(default)] - pub(crate) placeholder: Option, -} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct RecoverAssetCanvasGenerationsInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, -} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ArchiveAssetCanvasGenerationInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) expected_draft_revision: u64, - pub(crate) draft_id: String, - pub(crate) generation_id: String, -} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct FinalizeAssetCanvasGenerationFailureInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) expected_draft_revision: u64, - pub(crate) draft_id: String, - pub(crate) generation_id: String, - pub(crate) intent_id: String, - pub(crate) error_code: String, - pub(crate) reconciliation_required: bool, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ArchiveAssetCanvasGenerationResult { - pub(crate) generation_id: String, - pub(crate) phase: String, - pub(crate) archived_at: u64, - pub(crate) draft: AssetCanvasDraft, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct FinalizeAssetCanvasGenerationFailureResult { - pub(crate) disposition: String, - pub(crate) draft: AssetCanvasDraft, -} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) generation_id: String, - pub(crate) operation_id: Option, - pub(crate) challenge: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AssetCanvasGenerationProgressEvent { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) draft_id: String, - pub(crate) intent_id: String, - pub(crate) generation_id: String, - pub(crate) draft_revision: u64, - pub(crate) phase: String, - pub(crate) progress: Option, - pub(crate) error_code: Option, - pub(crate) occurred_at: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AssetCanvasGeneratedImage { - pub(crate) media_ref: AssetCanvasMediaRef, - pub(crate) resource_id: String, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AssetCanvasGenerationCommitResult { - pub(crate) resource_id: String, - pub(crate) asset_id: String, - pub(crate) project_id: String, - pub(crate) commit_id: String, - pub(crate) committed_project_revision: u64, - pub(crate) draft_revision: u64, - pub(crate) host_revision: String, - pub(crate) commit_status: String, - pub(crate) manifest: GameCreationAppManifest, - pub(crate) event_id: String, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GenerateAssetCanvasImageResult { - pub(crate) generation: AssetCanvasGenerationRecord, - pub(crate) images: Vec, - pub(crate) draft: AssetCanvasDraft, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) commit: Option, -} - -pub(crate) struct GenerateAssetCanvasImageExecution { - pub(crate) result: GenerateAssetCanvasImageResult, - pub(crate) event: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct RecoverAssetCanvasGenerationsResult { - pub(crate) resumed_generation_ids: Vec, - pub(crate) service_identity_confirmations: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AssetCanvasServiceIdentityConfirmation { - pub(crate) generation_id: String, - pub(crate) operation_id: Option, - pub(crate) operation_state: String, - pub(crate) service_origin: String, - pub(crate) challenge: String, - pub(crate) expires_at: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityResult { - pub(crate) generation_id: String, - pub(crate) operation_id: Option, - pub(crate) operation_state: String, - pub(crate) service_origin: String, - pub(crate) identity_scheme: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AcknowledgeAssetCanvasCandidateLayersInput { - pub(crate) project_path: String, - pub(crate) expected_project_id: String, - pub(crate) draft_id: String, - pub(crate) layer_ids: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AcknowledgeAssetCanvasCandidateLayersResult { - pub(crate) draft: AssetCanvasDraft, -} - -pub(crate) struct RecoverAssetCanvasGenerationsExecution { - pub(crate) result: RecoverAssetCanvasGenerationsResult, - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum GenerationLedgerPhase { - ContextPreparing, - ReferencesPreparing, - Prepared, - Accepted, - Running, - RemoteCompleted, - MediaDownloaded, - CandidateReady, - AssetCommitSettlementPending, - AssetDurableCommitted, - FailureSettlementPending, - ReconciliationSettlementPending, - Failed, - ArchivePending, - Archived, - ReconciliationRequired, -} - -impl GenerationLedgerPhase { - fn as_str(&self) -> &'static str { - match self { - Self::ContextPreparing => "context-preparing", - Self::ReferencesPreparing => "references-preparing", - Self::Prepared => "prepared", - Self::Accepted => "accepted", - Self::Running => "running", - Self::RemoteCompleted => "remote-completed", - Self::MediaDownloaded => "media-downloaded", - Self::CandidateReady => "candidate-ready", - Self::AssetCommitSettlementPending => "asset-commit-settlement-pending", - Self::AssetDurableCommitted => "asset-durable-committed", - Self::FailureSettlementPending => "failure-settlement-pending", - Self::ReconciliationSettlementPending => "reconciliation-settlement-pending", - Self::Failed => "failed", - Self::ArchivePending => "archive-pending", - Self::Archived => "archived", - Self::ReconciliationRequired => "reconciliation-required", - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateCanvasContext { - project_id: String, - asset_folder_id: String, - canvas_name: String, -} - -impl From for PrivateCanvasContext { - fn from(value: ExternalCanvasGenerationContext) -> Self { - Self { - project_id: value.project_id, - asset_folder_id: value.asset_folder_id, - canvas_name: value.canvas_name, - } - } -} - -#[derive(Clone)] -struct PrivateUploadTicket { - host: String, - bucket: String, - object_key: String, - success_action_status: u16, - form_fields: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateReferenceState { - resource_id: String, - stable_reference: Option, - asset_object_id: Option, - upload_bucket: Option, - upload_object_key: Option, - upload_completed: bool, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateRemoteResult { - resource_id: String, - object_key: String, - asset_object_id: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateCommitResult { - resource_id: String, - asset_id: String, - project_id: String, - commit_id: String, - committed_project_revision: u64, - draft_revision: u64, - host_revision: u64, - commit_status: String, - event_id: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateServiceIdentityConfirmation { - challenge: String, - service_fingerprint: String, - ledger_snapshot_sha256: String, - expires_at: u64, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AssetCanvasGenerationLedger { - schema_version: String, - request_fingerprint: String, - project_id: String, - draft_id: String, - intent: AssetCanvasIntent, - source_asset_id: Option, - source_resource_id: Option, - #[serde(default)] - source_layer_id: Option, - #[serde(default)] - placeholder: Option, - intent_id: String, - generation_id: String, - idempotency_key: String, - commit_id: String, - commit_idempotency_key: String, - expected_host_revision: u64, - expected_draft_revision: u64, - #[serde(default)] - current_draft_revision: Option, - prompt: String, - aspect_ratio: String, - image_size: String, - asset_kind: String, - asset_name: String, - requested_reference_resource_ids: Vec, - reference_states: Vec, - resolved_reference_ids: Vec, - #[serde(default)] - #[serde(alias = "canonicalReferenceRepairAttempted")] - reference_binding_repair_attempted: bool, - access_scheme: Option, - #[serde(default)] - api_identity_scheme: Option, - #[serde(default, alias = "externalConfigurationFingerprint")] - api_identity_fingerprint: Option, - #[serde(default)] - platform_owner_user_id: Option, - #[serde(default)] - platform_owner_api_base_url: Option, - #[serde(default)] - service_identity_confirmation: Option, - canvas_context: Option, - endpoint: Option, - request_body_sha256: Option, - request_body_json: Option, - phase: GenerationLedgerPhase, - operation_id: Option, - poll_after_ms: Option, - remote_result: Option, - staged_image_token: String, - #[serde(default)] - staged_draft_revision: Option, - #[serde(default)] - candidate_media_ref: Option, - #[serde(default)] - candidate_layer_id: Option, - #[serde(default)] - candidate_layer_acknowledged_revision: Option, - commit_result: Option, - error_code: Option, - #[serde(default)] - archived_at: Option, - created_at: u64, - updated_at: u64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct NormalizedGenerationRequest<'a> { - project_id: &'a str, - draft_id: &'a str, - intent: &'a AssetCanvasIntent, - source_asset_id: &'a Option, - source_layer_id: &'a Option, - placeholder: &'a Option, - intent_id: &'a str, - generation_id: &'a str, - idempotency_key: &'a str, - commit_id: &'a str, - commit_idempotency_key: &'a str, - expected_host_revision: u64, - expected_draft_revision: u64, - prompt: &'a str, - aspect_ratio: &'a str, - image_size: &'a str, - asset_kind: &'a str, - asset_name: &'a str, - reference_resource_ids: &'a [String], -} - -struct ReferenceMaterial { - stable_reference: Option, - local_asset_id: Option, - asset_kind: Option, - bytes: Option>, - media_type: Option, - file_name: Option, - sha256: Option, - width: Option, - height: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ReferencePreparationError { - AuthenticationRequired, - PermissionDenied, - MaterialInvalid, - TicketFailed, - ObjectUploadFailed, - ConfirmFailed, - Internal, -} - -impl ReferencePreparationError { - fn code(self) -> &'static str { - match self { - Self::AuthenticationRequired => "authentication-required", - Self::PermissionDenied => "permission-denied", - Self::MaterialInvalid => "reference-material-invalid", - Self::TicketFailed => "reference-ticket-failed", - Self::ObjectUploadFailed => "reference-object-upload-failed", - Self::ConfirmFailed => "reference-confirm-failed", - Self::Internal => "generation-failed", - } - } - - fn from_http_error(error: &str, fallback: Self) -> Self { - if error.contains("HTTP 401") - || error.contains("authentication-required") - || error.contains("登录态已变化") - { - Self::AuthenticationRequired - } else if error.contains("HTTP 403") { - Self::PermissionDenied - } else { - fallback - } - } -} - -impl From for ReferencePreparationError { - fn from(_: String) -> Self { - Self::Internal - } -} - -fn validate_reference_binding_access( - access: &ExternalEditorBindingAccess<'_>, -) -> Result<(), ReferencePreparationError> { - access.validate_frozen_session().map_err(|error| { - ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) - }) -} - -async fn acquire_reference_binding_singleflight( - root: &Path, - binding_key_sha256: &str, - access: &ExternalEditorBindingAccess<'_>, -) -> Result { - acquire_external_editor_resource_binding_singleflight(root, binding_key_sha256, access) - .await - .map_err(|error| { - ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) - }) -} - -fn install_registered_reference_binding( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - index: usize, - binding: ExternalEditorResourceBinding, -) -> Result { - let Some(remote_resource_id) = binding.remote_resource_id else { - return Ok(false); - }; - ledger.reference_states[index].stable_reference = Some(remote_resource_id); - ledger.reference_states[index].asset_object_id = Some(binding.asset_object_id); - ledger.reference_states[index].upload_bucket = None; - ledger.reference_states[index].upload_object_key = None; - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger).map_err(|_| ReferencePreparationError::Internal)?; - Ok(true) -} - -struct CanvasGenerationApiMode { - api_key: String, -} - -impl CanvasGenerationApiMode { - fn bearer_token(&self) -> &str { - &self.api_key - } -} - -fn resolve_generation_api_mode() -> Result< - ( - String, - CanvasGenerationApiMode, - Option, - ), - String, -> { - let (api_base_url, api_key, platform_session) = - resolve_canvas_sync_api_credentials(None, None)?; - Ok(( - api_base_url, - CanvasGenerationApiMode { api_key }, - platform_session, - )) -} - -fn initialize_generation_access_identity( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - api_base_url: &str, - session: Option<&PlatformSessionSnapshot>, -) -> Result<(), String> { - with_frozen_generation_platform_session_commit(session, || { - if ledger.access_scheme.is_some() - || ledger.api_identity_scheme.is_some() - || ledger.api_identity_fingerprint.is_some() - || ledger.platform_owner_user_id.is_some() - || ledger.platform_owner_api_base_url.is_some() - { - return Err("素材画布首次访问主体只能写入一次".to_string()); - } - ledger.access_scheme = Some( - if session.is_some() { - ASSET_CANVAS_PLATFORM_ACCESS_SCHEME - } else { - ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME - } - .to_string(), - ); - ledger.api_identity_scheme = - Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some( - platform_art_generation_external_service_fingerprint(api_base_url, session), - ); - if let Some(session) = session { - ledger.platform_owner_user_id = Some(session.user_id.clone()); - ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); - } - write_generation_ledger(root, ledger) - }) -} - -fn bind_generation_platform_owner( - ledger: &AssetCanvasGenerationLedger, - session: Option<&PlatformSessionSnapshot>, -) -> Result<(), String> { - with_frozen_generation_platform_session_commit(session, || { - match ( - ledger.access_scheme.as_deref(), - ledger.platform_owner_user_id.as_deref(), - ledger.platform_owner_api_base_url.as_deref(), - session, - ) { - ( - Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME), - Some(user_id), - Some(api_base_url), - Some(session), - ) if user_id == session.user_id && api_base_url == session.api_base_url => Ok(()), - (Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), - _ => Err( - "authentication-required: 此素材生成缺少完整访问主体或属于其他陶泥儿账号,当前账号不能恢复或提交".to_string(), - ), - } - }) -} - -fn validate_generation_access_identity( - ledger: &AssetCanvasGenerationLedger, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result<(), String> { - match ( - ledger.access_scheme.as_deref(), - ledger.platform_owner_user_id.as_deref(), - ledger.platform_owner_api_base_url.as_deref(), - platform_session, - ) { - ( - Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME), - Some(owner_user_id), - Some(owner_api_base_url), - Some(session), - ) if owner_user_id == session.user_id.as_str() - && owner_api_base_url == session.api_base_url.as_str() => - { - Ok(()) - } - (Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), - _ => Err( - "authentication-required: 素材画布访问模式、服务地址或账本 owner 与当前调用主体不一致" - .to_string(), - ), - } -} - -fn ensure_frozen_generation_platform_session( - session: Option<&PlatformSessionSnapshot>, -) -> Result<(), String> { - if let Some(session) = session { - validate_platform_session_snapshot(session)?; - } - Ok(()) -} - -fn with_frozen_generation_platform_session_commit( - session: Option<&PlatformSessionSnapshot>, - action: impl FnOnce() -> Result, -) -> Result { - let Some(session) = session else { - return action(); - }; - with_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &asset_canvas_sha256(session.access_token.as_bytes()), - action, - ) -} - -fn canvas_api_identity_fingerprint( - api_base_url: &str, - mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> String { - let _ = mode; - platform_art_generation_external_service_fingerprint(api_base_url, platform_session) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum CanvasServiceIdentityDecision { - Ready, - ConfirmationRequired(AssetCanvasServiceIdentityConfirmation), -} - -fn generation_service_identity_snapshot_sha256( - ledger: &AssetCanvasGenerationLedger, -) -> Result { - let snapshot = serde_json::json!({ - "projectId": ledger.project_id, - "draftId": ledger.draft_id, - "generationId": ledger.generation_id, - "idempotencyKey": ledger.idempotency_key, - "phase": ledger.phase, - "operationId": ledger.operation_id, - "endpoint": ledger.endpoint, - "requestBodySha256": ledger.request_body_sha256, - "accessScheme": ledger.access_scheme, - "apiIdentityScheme": ledger.api_identity_scheme, - "apiIdentityFingerprint": ledger.api_identity_fingerprint, - }); - let bytes = - serde_json::to_vec(&snapshot).map_err(|_| "序列化素材画布服务身份快照失败".to_string())?; - Ok(asset_canvas_sha256(&bytes)) -} - -fn generation_has_service_identity_evidence(ledger: &AssetCanvasGenerationLedger) -> bool { - ledger.access_scheme.is_some() - || ledger.platform_owner_user_id.is_some() - || ledger.platform_owner_api_base_url.is_some() - || ledger.canvas_context.is_some() - || ledger.endpoint.is_some() - || ledger.request_body_json.is_some() - || ledger.operation_id.is_some() - || !ledger.reference_states.is_empty() - || ledger.phase != GenerationLedgerPhase::ContextPreparing -} - -fn validate_generation_service_identity_owner( - ledger: &AssetCanvasGenerationLedger, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result<(), String> { - validate_generation_access_identity(ledger, platform_session) -} - -fn public_service_identity_confirmation( - ledger: &AssetCanvasGenerationLedger, - service_origin: String, - confirmation: &PrivateServiceIdentityConfirmation, -) -> AssetCanvasServiceIdentityConfirmation { - AssetCanvasServiceIdentityConfirmation { - generation_id: ledger.generation_id.clone(), - operation_id: ledger.operation_id.clone(), - operation_state: ledger.phase.as_str().to_string(), - service_origin, - challenge: confirmation.challenge.clone(), - expires_at: confirmation.expires_at, - } -} - -fn prepare_generation_service_identity( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result { - with_frozen_generation_platform_session_commit(platform_session, || { - prepare_generation_service_identity_under_lease( - root, - ledger, - api_base_url, - api_mode, - platform_session, - ) - }) -} - -fn prepare_generation_service_identity_under_lease( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result { - validate_generation_service_identity_owner(ledger, platform_session)?; - let fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); - let identity_match = classify_platform_art_generation_service_identity( - ledger.api_identity_scheme.as_deref(), - ledger.api_identity_fingerprint.as_deref(), - api_base_url, - api_mode.bearer_token(), - platform_session, - ); - match identity_match { - PlatformArtGenerationServiceIdentityMatch::Current - | PlatformArtGenerationServiceIdentityMatch::LegacyVerified => { - if ledger.api_identity_scheme.as_deref() - != Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) - || ledger.api_identity_fingerprint.as_deref() != Some(fingerprint.as_str()) - || ledger.service_identity_confirmation.is_some() - { - ledger.api_identity_scheme = - Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some(fingerprint); - ledger.service_identity_confirmation = None; - write_generation_ledger(root, ledger)?; - } - Ok(CanvasServiceIdentityDecision::Ready) - } - PlatformArtGenerationServiceIdentityMatch::Unbound - if !generation_has_service_identity_evidence(ledger) => - { - ledger.api_identity_scheme = - Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some(fingerprint); - ledger.service_identity_confirmation = None; - write_generation_ledger(root, ledger)?; - Ok(CanvasServiceIdentityDecision::Ready) - } - PlatformArtGenerationServiceIdentityMatch::LegacyUnverified - | PlatformArtGenerationServiceIdentityMatch::Unbound => { - let service_origin = platform_art_generation_external_service_origin(api_base_url)?; - let ledger_snapshot_sha256 = generation_service_identity_snapshot_sha256(ledger)?; - let now = asset_canvas_now(); - let confirmation_is_current = ledger - .service_identity_confirmation - .as_ref() - .is_some_and(|confirmation| { - confirmation.service_fingerprint == fingerprint - && confirmation.ledger_snapshot_sha256 == ledger_snapshot_sha256 - && confirmation.expires_at > now - }); - if !confirmation_is_current { - let expires_at = now - .checked_add(ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS) - .ok_or_else(|| "素材画布服务身份确认有效期溢出".to_string())?; - ledger.service_identity_confirmation = Some(PrivateServiceIdentityConfirmation { - challenge: new_asset_canvas_token()?, - service_fingerprint: fingerprint, - ledger_snapshot_sha256, - expires_at, - }); - write_generation_ledger(root, ledger)?; - } - let confirmation = ledger - .service_identity_confirmation - .as_ref() - .ok_or_else(|| "素材画布服务身份确认挑战缺失".to_string())?; - Ok(CanvasServiceIdentityDecision::ConfirmationRequired( - public_service_identity_confirmation(ledger, service_origin, confirmation), - )) - } - PlatformArtGenerationServiceIdentityMatch::Changed => { - ledger.service_identity_confirmation = None; - Err(sanitized_generation_error("configuration-changed")) - } - } -} - -fn authorize_canvas_request( - request: reqwest::RequestBuilder, - mode: &CanvasGenerationApiMode, -) -> reqwest::RequestBuilder { - request.bearer_auth(mode.bearer_token()) -} - -fn generation_ledger_relative_path(generation_id: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/generations/{generation_id}.json") -} - -fn generation_progress_event( - ledger: &AssetCanvasGenerationLedger, - phase: &str, - progress: Option, - error_code: Option, -) -> AssetCanvasGenerationProgressEvent { - AssetCanvasGenerationProgressEvent { - schema_version: "game-creator-asset-generation-progress.v1".to_string(), - project_id: ledger.project_id.clone(), - draft_id: ledger.draft_id.clone(), - intent_id: ledger.intent_id.clone(), - generation_id: ledger.generation_id.clone(), - draft_revision: ledger - .current_draft_revision - .unwrap_or(ledger.expected_draft_revision), - phase: phase.to_string(), - progress, - error_code, - occurred_at: asset_canvas_now(), - } -} - -fn public_phase(phase: &GenerationLedgerPhase) -> Option { - match phase { - GenerationLedgerPhase::Accepted => Some(AssetCanvasGenerationStatus::GenerationAccepted), - GenerationLedgerPhase::Running => Some(AssetCanvasGenerationStatus::GenerationRunning), - GenerationLedgerPhase::RemoteCompleted => { - Some(AssetCanvasGenerationStatus::RemoteCompleted) - } - GenerationLedgerPhase::MediaDownloaded => { - Some(AssetCanvasGenerationStatus::MediaDownloaded) - } - GenerationLedgerPhase::CandidateReady => Some(AssetCanvasGenerationStatus::CandidateReady), - GenerationLedgerPhase::AssetCommitSettlementPending => None, - GenerationLedgerPhase::AssetDurableCommitted => { - Some(AssetCanvasGenerationStatus::AssetDurableCommitted) - } - GenerationLedgerPhase::FailureSettlementPending - | GenerationLedgerPhase::ReconciliationSettlementPending => None, - GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed), - GenerationLedgerPhase::ArchivePending => None, - GenerationLedgerPhase::Archived => None, - GenerationLedgerPhase::ReconciliationRequired => { - Some(AssetCanvasGenerationStatus::ReconciliationRequired) - } - GenerationLedgerPhase::ContextPreparing - | GenerationLedgerPhase::ReferencesPreparing - | GenerationLedgerPhase::Prepared => None, - } -} - -fn public_phase_name(phase: &AssetCanvasGenerationStatus) -> &'static str { - match phase { - AssetCanvasGenerationStatus::GenerationAccepted => "generation-accepted", - AssetCanvasGenerationStatus::GenerationRunning => "generation-running", - AssetCanvasGenerationStatus::RemoteCompleted => "remote-completed", - AssetCanvasGenerationStatus::MediaDownloaded => "media-downloaded", - AssetCanvasGenerationStatus::CandidateReady => "candidate-ready", - AssetCanvasGenerationStatus::AssetDurableCommitted => "asset-durable-committed", - AssetCanvasGenerationStatus::Failed => "failed", - AssetCanvasGenerationStatus::ReconciliationRequired => "reconciliation-required", - } -} - -fn public_progress_value(phase: &AssetCanvasGenerationStatus) -> Option { - match phase { - AssetCanvasGenerationStatus::GenerationAccepted => Some(10), - AssetCanvasGenerationStatus::GenerationRunning => Some(35), - AssetCanvasGenerationStatus::RemoteCompleted => Some(60), - AssetCanvasGenerationStatus::MediaDownloaded => Some(80), - AssetCanvasGenerationStatus::CandidateReady => Some(100), - AssetCanvasGenerationStatus::AssetDurableCommitted => Some(100), - AssetCanvasGenerationStatus::Failed - | AssetCanvasGenerationStatus::ReconciliationRequired => None, - } -} - -fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<(), String> { - if ledger.schema_version != ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION { - return Err("素材画布私有生成账本版本无效".to_string()); - } - for (value, label) in [ - (&ledger.intent_id, "intentId"), - (&ledger.generation_id, "generationId"), - (&ledger.idempotency_key, "idempotencyKey"), - (&ledger.commit_id, "commitId"), - (&ledger.commit_idempotency_key, "commitIdempotencyKey"), - (&ledger.staged_image_token, "stagedImageToken"), - ] { - validate_uuid_v4(value, label)?; - } - validate_safe_revision(ledger.expected_host_revision, "expectedHostRevision")?; - validate_safe_revision(ledger.expected_draft_revision, "expectedDraftRevision")?; - if let Some(revision) = ledger.current_draft_revision { - validate_safe_revision(revision, "currentDraftRevision")?; - if revision < ledger.expected_draft_revision { - return Err("素材画布私有生成账本草稿 revision 倒退".to_string()); - } - } - if let Some(revision) = ledger.staged_draft_revision { - validate_safe_revision(revision, "stagedDraftRevision")?; - } - if let Some(source_layer_id) = ledger.source_layer_id.as_deref() { - validate_plain_component(source_layer_id, "sourceLayerId", 512)?; - } - if let Some(placeholder) = ledger.placeholder.as_ref() { - validate_asset_canvas_generation_placeholder(placeholder)?; - } - if ledger.prompt.is_empty() - || ledger.prompt.chars().count() > 32_000 - || ledger.request_fingerprint.len() != 64 - || ledger.reference_states.len() > ASSET_CANVAS_GENERATION_REFERENCE_LIMIT - || ledger.resolved_reference_ids.len() > ASSET_CANVAS_GENERATION_REFERENCE_LIMIT - { - return Err("素材画布私有生成账本内容无效".to_string()); - } - if ledger.api_identity_scheme.as_ref().is_some_and(|value| { - value.is_empty() || value.len() > 64 || value.chars().any(char::is_control) - }) || ledger - .api_identity_fingerprint - .as_ref() - .is_some_and(|value| { - value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) - }) - { - return Err("素材画布私有生成账本服务身份无效".to_string()); - } - if ledger.access_scheme.as_deref().is_some_and(|scheme| { - !matches!( - scheme, - ASSET_CANVAS_PLATFORM_ACCESS_SCHEME | ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME - ) - }) || ledger.platform_owner_user_id.as_ref().is_some_and(|value| { - value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) - }) || ledger - .platform_owner_api_base_url - .as_ref() - .is_some_and(|value| { - value.trim().is_empty() || value.len() > 2_048 || value.chars().any(char::is_control) - }) - { - return Err("素材画布私有生成账本访问主体无效".to_string()); - } - if let Some(confirmation) = ledger.service_identity_confirmation.as_ref() { - validate_safe_revision( - confirmation.expires_at, - "serviceIdentityConfirmation.expiresAt", - )?; - if confirmation.challenge.len() < 32 - || confirmation.challenge.len() > 128 - || confirmation.challenge.chars().any(char::is_control) - || confirmation.service_fingerprint.len() != 64 - || !confirmation - .service_fingerprint - .chars() - .all(|character| character.is_ascii_hexdigit()) - || confirmation.ledger_snapshot_sha256.len() != 64 - || !confirmation - .ledger_snapshot_sha256 - .chars() - .all(|character| character.is_ascii_hexdigit()) - { - return Err("素材画布私有生成账本服务身份确认无效".to_string()); - } - } - for state in &ledger.reference_states { - if state.resource_id.trim().is_empty() - || state.resource_id.chars().count() > 512 - || state.resource_id.chars().any(char::is_control) - || state.upload_bucket.is_some() != state.upload_object_key.is_some() - || state.upload_bucket.as_ref().is_some_and(|value| { - value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) - }) - || state.upload_object_key.as_ref().is_some_and(|value| { - value.is_empty() || value.len() > 2048 || value.chars().any(char::is_control) - }) - { - return Err("素材画布私有生成参考状态无效".to_string()); - } - } - match (&ledger.phase, ledger.archived_at) { - ( - GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived, - Some(archived_at), - ) => { - validate_safe_revision(archived_at, "archivedAt")?; - } - (GenerationLedgerPhase::ArchivePending, None) => { - return Err("待归档的素材画布生成账本缺少 archivedAt".to_string()); - } - (GenerationLedgerPhase::Archived, None) => { - return Err("已归档的素材画布生成账本缺少 archivedAt".to_string()); - } - (_, Some(_)) => { - return Err("未归档的素材画布生成账本不能包含 archivedAt".to_string()); - } - (_, None) => {} - } - if matches!( - ledger.phase, - GenerationLedgerPhase::FailureSettlementPending - | GenerationLedgerPhase::ReconciliationSettlementPending - ) && ledger.error_code.is_none() - { - return Err("待结算的素材画布生成账本缺少 errorCode".to_string()); - } - if let (Some(body), Some(expected_sha)) = ( - ledger.request_body_json.as_deref(), - ledger.request_body_sha256.as_deref(), - ) { - if asset_canvas_sha256(body.as_bytes()) != expected_sha { - return Err("素材画布私有生成请求快照摘要不匹配".to_string()); - } - } else if ledger.request_body_json.is_some() || ledger.request_body_sha256.is_some() { - return Err("素材画布私有生成请求快照不完整".to_string()); - } - Ok(()) -} - -fn read_generation_ledger( - root: &Path, - generation_id: &str, -) -> Result, String> { - validate_uuid_v4(generation_id, "generationId")?; - let ledger = read_agent_runtime_json_sidecar_with_max_bytes::( - root, - &generation_ledger_relative_path(generation_id), - "素材画布私有生成账本", - ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES, - )?; - if let Some(ledger) = ledger.as_ref() { - validate_generation_ledger(ledger)?; - } - Ok(ledger) -} - -fn write_generation_ledger( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result<(), String> { - ledger.updated_at = asset_canvas_now(); - validate_generation_ledger(ledger)?; - #[cfg(test)] - wait_asset_canvas_account_bound_write_test_barrier(); - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &generation_ledger_relative_path(&ledger.generation_id), - "素材画布私有生成账本", - ledger, - ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES, - ) -} - -fn set_private_phase( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - phase: GenerationLedgerPhase, - error_code: Option<&str>, -) -> Result<(), String> { - ledger.phase = phase; - ledger.error_code = error_code.map(str::to_string); - write_generation_ledger(root, ledger) -} - -fn retryable_credential_failure_phase( - ledger: &AssetCanvasGenerationLedger, -) -> Option { - if ledger.phase != GenerationLedgerPhase::Failed - || !matches!( - ledger.error_code.as_deref(), - Some("configuration-missing" | "authentication-required") - ) - { - return None; - } - Some(if ledger.operation_id.is_some() { - GenerationLedgerPhase::ReconciliationRequired - } else if ledger.request_body_json.is_some() { - GenerationLedgerPhase::Prepared - } else { - GenerationLedgerPhase::ContextPreparing - }) -} - -fn migrate_retryable_credential_failure( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result { - let Some(phase) = retryable_credential_failure_phase(ledger) else { - return Ok(false); - }; - ensure_generation_draft_active(root, ledger)?; - ledger.phase = phase; - write_generation_ledger(root, ledger)?; - Ok(true) -} - -fn validate_generation_draft_active( - ledger: &AssetCanvasGenerationLedger, - draft: &AssetCanvasDraft, -) -> Result<(), String> { - if draft.project_id != ledger.project_id - || draft.draft_id != ledger.draft_id - || draft.intent != ledger.intent - || draft.source_asset_id != ledger.source_asset_id - { - return Err("素材画布生成账本与草稿身份不一致".to_string()); - } - if draft.status == AssetCanvasDraftStatus::Cancelled { - return Err( - "generation-cancelled: 素材画布草稿已取消;私有生成账本将保留,但不得继续公开投影或提交资产" - .to_string(), - ); - } - if draft.status == AssetCanvasDraftStatus::Committed { - return Err("素材画布草稿已经提交,不能继续推进其它生成账本".to_string()); - } - Ok(()) -} - -fn ensure_generation_draft_active( - root: &Path, - ledger: &AssetCanvasGenerationLedger, -) -> Result<(), String> { - let _lock = acquire_asset_canvas_draft_lock(root)?; - let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - validate_generation_draft_active(ledger, &draft) -} - -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 mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - validate_generation_draft_active(ledger, &draft)?; - let now = asset_canvas_now(); - let output_asset_id = ledger - .commit_result - .as_ref() - .map(|result| result.asset_id.clone()); - let references = normalize_asset_canvas_references(&ledger.requested_reference_resource_ids)?; - let index = draft - .generations - .iter() - .position(|record| record.generation_id == ledger.generation_id); - let placeholder = index - .and_then(|index| draft.generations.get(index)) - .and_then(|record| record.placeholder.clone()) - .or_else(|| ledger.placeholder.clone()); - let record = AssetCanvasGenerationRecord { - generation_id: ledger.generation_id.clone(), - intent_id: ledger.intent_id.clone(), - phase, - reference_resource_ids: references, - output_asset_id, - source_layer_id: ledger.source_layer_id.clone(), - placeholder, - error_code: ledger.error_code.clone(), - created_at: index - .and_then(|index| draft.generations.get(index)) - .map(|record| record.created_at) - .unwrap_or(ledger.created_at), - updated_at: now, - idempotency_key: None, - status: None, - prompt: None, - operation_id: None, - output_media_ids: Vec::new(), - }; - if let Some(index) = index { - let current = &draft.generations[index]; - if current.intent_id != ledger.intent_id { - return Err("generationId 已绑定到不同 intentId".to_string()); - } - draft.generations[index] = record.clone(); - } else { - if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS { - return Err("素材画布生成记录数量已达上限".to_string()); - } - draft.generations.push(record.clone()); - } - if ledger.phase == GenerationLedgerPhase::Failed { - draft.status = AssetCanvasDraftStatus::Editing; - } else if !matches!( - ledger.phase, - GenerationLedgerPhase::CandidateReady | GenerationLedgerPhase::AssetDurableCommitted - ) { - draft.status = AssetCanvasDraftStatus::Generating; - } - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = now; - write_asset_canvas_draft_locked(root, &draft)?; - 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, - emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), -) -> Result { - synchronize_staged_image_revision(root, ledger)?; - let previous_staged_revision = ledger.staged_draft_revision; - let (record, draft_revision) = upsert_public_generation_record(root, ledger)?; - ledger.current_draft_revision = Some(draft_revision); - write_generation_ledger(root, ledger)?; - if let Some(previous_revision) = previous_staged_revision { - rebind_asset_canvas_staged_image_revision_at( - 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)?; - } - emit(generation_progress_event( - ledger, - public_phase_name(&record.phase), - public_progress_value(&record.phase), - record.error_code.clone(), - )); - Ok(record) -} - -fn synchronize_staged_image_revision( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result { - let (metadata, _) = match read_staged_image_locked(root, &ledger.staged_image_token) { - Ok(staged) => staged, - Err(error) if error == "素材画布 staging 元数据不存在" => return Ok(false), - Err(error) => return Err(error), - }; - if metadata.project_id != ledger.project_id || metadata.draft_id != ledger.draft_id { - return Err("staging 图片与生成账本身份不匹配".to_string()); - } - let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if metadata.draft_revision != draft.revision { - rebind_asset_canvas_staged_image_revision_at( - root, - &ledger.staged_image_token, - &ledger.project_id, - &ledger.draft_id, - metadata.draft_revision, - draft.revision, - )?; - } - let changed = ledger.current_draft_revision != Some(draft.revision) - || ledger.staged_draft_revision != Some(draft.revision); - ledger.current_draft_revision = Some(draft.revision); - ledger.staged_draft_revision = Some(draft.revision); - if changed { - write_generation_ledger(root, ledger)?; - } - Ok(true) -} - -fn encode_generation_candidate_png(bytes: &[u8]) -> Result<(Vec, u32, u32), String> { - let image = image::load_from_memory(bytes).map_err(|_| "生成图片无法完整解码".to_string())?; - let (width, height) = image.dimensions(); - let mut png = std::io::Cursor::new(Vec::new()); - image - .write_to(&mut png, image::ImageFormat::Png) - .map_err(|_| "生成图片转换 PNG 失败".to_string())?; - let png = png.into_inner(); - validate_image_bytes("image/png", &png)?; - Ok((png, width, height)) -} - -fn persist_generation_candidate_at( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result< - ( - AssetCanvasGenerationRecord, - AssetCanvasDraft, - AssetCanvasGeneratedImage, - ), - String, -> { - let (_, staged_bytes) = read_staged_image_locked(root, &ledger.staged_image_token)?; - let (png_bytes, width, height) = encode_generation_candidate_png(&staged_bytes)?; - let media_id = ledger.generation_id.clone(); - let resource_id = format!("draft-media:{media_id}"); - let layer_id = ledger - .candidate_layer_id - .clone() - .unwrap_or_else(|| ledger.generation_id.clone()); - let media_ref = AssetCanvasMediaRef::DraftMedia { - media_id: media_id.clone(), - media_type: "image/png".to_string(), - sha256: asset_canvas_sha256(&png_bytes), - byte_length: png_bytes.len() as u64, - pixel_width: width, - pixel_height: height, - }; - let relative = format!( - "{ASSET_CANVAS_ROOT}/media/{}/{media_id}.png", - ledger.draft_id - ); - let path = resolve_local_project_path(root, &relative)?; - match fs::symlink_metadata(&path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err("生成候选图片目标必须是普通文件".to_string()); - } - Ok(_) => { - open_and_validate_image_file( - &path, - "image/png", - Some(&asset_canvas_sha256(&png_bytes)), - )?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - if asset_canvas_draft_media_bytes(root, &ledger.draft_id)? - .checked_add(png_bytes.len() as u64) - .is_none_or(|total| total > ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) - { - return Err("单个素材画布草稿媒体总量超过 512 MiB".to_string()); - } - install_new_asset_canvas_file(&path, &png_bytes, "生成候选图片")?; - } - Err(error) => return Err(format!("读取生成候选图片失败:{error}")), - } - - 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())?; - if draft.intent != ledger.intent || draft.source_asset_id != ledger.source_asset_id { - return Err("素材画布生成账本与草稿身份不一致".to_string()); - } - let existing_layer = draft - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == layer_id); - if let Some(existing) = existing_layer { - if existing.resource_id != resource_id || existing.media_ref != media_ref { - return Err("生成候选图层身份已绑定到不同媒体".to_string()); - } - if ledger.placeholder.as_ref().is_some_and(|placeholder| { - existing.x != placeholder.x - || existing.y != placeholder.y - || existing.width != placeholder.width - || existing.height != placeholder.height - }) { - return Err("生成候选图层已偏离持久化占位".to_string()); - } - } else { - let anchor = ledger - .source_layer_id - .as_deref() - .and_then(|source_layer_id| { - draft - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == source_layer_id) - }) - .or_else(|| { - draft.canvas.layers.iter().find(|layer| { - ledger - .source_resource_id - .as_deref() - .is_some_and(|source| layer.resource_id == source) - }) - }); - let (x, y, display_width, display_height) = ledger - .placeholder - .as_ref() - .map(|placeholder| { - ( - placeholder.x, - placeholder.y, - placeholder.width, - placeholder.height, - ) - }) - .unwrap_or_else(|| { - ( - anchor - .map(|layer| layer.x + layer.width + 80.0) - .unwrap_or(6000.0 - f64::from(width) / 2.0), - anchor - .map(|layer| layer.y) - .unwrap_or(6000.0 - f64::from(height) / 2.0), - f64::from(width), - f64::from(height), - ) - }); - let z_index = draft - .canvas - .layers - .iter() - .map(|layer| layer.z_index) - .max() - .unwrap_or(0) - .checked_add(1) - .ok_or_else(|| "生成候选图层层序已达到上限".to_string())?; - draft.canvas.layers.push(AssetCanvasLayer { - layer_id: layer_id.clone(), - resource_id: resource_id.clone(), - title: format!("{} 候选", ledger.asset_name), - media_ref: media_ref.clone(), - x, - y, - width: display_width, - height: display_height, - original_width: f64::from(width), - original_height: f64::from(height), - z_index, - group_id: None, - hidden: false, - locked: false, - flip_x: false, - flip_y: false, - }); - } - draft.canvas.selected_layer_ids = vec![layer_id.clone()]; - draft.canvas.primary_selected_layer_id = Some(layer_id.clone()); - let now = asset_canvas_now(); - let record = AssetCanvasGenerationRecord { - generation_id: ledger.generation_id.clone(), - intent_id: ledger.intent_id.clone(), - phase: AssetCanvasGenerationStatus::CandidateReady, - reference_resource_ids: normalize_asset_canvas_references( - &ledger.requested_reference_resource_ids, - )?, - output_asset_id: None, - source_layer_id: ledger.source_layer_id.clone(), - placeholder: ledger.placeholder.clone(), - error_code: None, - created_at: draft - .generations - .iter() - .find(|record| record.generation_id == ledger.generation_id) - .map(|record| record.created_at) - .unwrap_or(ledger.created_at), - updated_at: now, - idempotency_key: None, - status: None, - prompt: None, - operation_id: None, - output_media_ids: Vec::new(), - }; - if let Some(index) = draft - .generations - .iter() - .position(|record| record.generation_id == ledger.generation_id) - { - draft.generations[index] = record.clone(); - } else { - if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS { - return Err("素材画布生成记录数量已达上限".to_string()); - } - draft.generations.push(record.clone()); - } - draft.status = AssetCanvasDraftStatus::Editing; - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - draft.updated_at = now; - write_asset_canvas_draft_locked(root, &draft)?; - ledger.current_draft_revision = Some(draft.revision); - ledger.candidate_media_ref = Some(media_ref.clone()); - ledger.candidate_layer_id = Some(layer_id); - ledger.candidate_layer_acknowledged_revision = None; - write_generation_ledger(root, ledger)?; - Ok(( - record, - draft, - AssetCanvasGeneratedImage { - media_ref, - resource_id, - }, - )) -} - -pub(crate) fn protect_candidate_layers_before_frontend_update_at( - root: &Path, - current_draft: &AssetCanvasDraft, - incoming_canvas: &AssetCanvasState, -) -> Result { - let mut merged_canvas = incoming_canvas.clone(); - for record in ¤t_draft.generations { - if record.phase != AssetCanvasGenerationStatus::CandidateReady { - continue; - } - let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else { - continue; - }; - if ledger.project_id != current_draft.project_id - || ledger.draft_id != current_draft.draft_id - || ledger.generation_id != record.generation_id - || ledger.candidate_layer_acknowledged_revision.is_some() - { - continue; - } - let Some(layer_id) = ledger.candidate_layer_id.clone() else { - continue; - }; - let Some(candidate_layer) = current_draft - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == layer_id) - else { - continue; - }; - if !merged_canvas - .layers - .iter() - .any(|layer| layer.layer_id == layer_id) - { - merged_canvas.layers.push(candidate_layer.clone()); - } - } - Ok(merged_canvas) -} - -pub(crate) fn acknowledge_candidate_layers_at( - root: &Path, - input: &AcknowledgeAssetCanvasCandidateLayersInput, -) -> Result { - validate_uuid_v4(&input.draft_id, "draftId")?; - if input.layer_ids.len() > ASSET_CANVAS_MAX_LAYERS { - return Err("候选图层确认数量已达上限".to_string()); - } - for layer_id in &input.layer_ids { - validate_plain_component(layer_id, "layerId", 512)?; - } - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.project_id != manifest.project_id || draft.draft_id != input.draft_id { - return Err("素材画布候选确认身份不一致".to_string()); - } - - let requested_layer_ids = input - .layer_ids - .iter() - .map(String::as_str) - .collect::>(); - let authoritative_layer_ids = draft - .canvas - .layers - .iter() - .map(|layer| layer.layer_id.as_str()) - .collect::>(); - for record in &draft.generations { - if record.phase != AssetCanvasGenerationStatus::CandidateReady { - continue; - } - let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else { - continue; - }; - if ledger.project_id != draft.project_id - || ledger.draft_id != draft.draft_id - || ledger.generation_id != record.generation_id - || ledger.candidate_layer_acknowledged_revision.is_some() - { - continue; - } - let Some(layer_id) = ledger.candidate_layer_id.as_deref() else { - continue; - }; - if requested_layer_ids.contains(&layer_id) && authoritative_layer_ids.contains(layer_id) { - ledger.candidate_layer_acknowledged_revision = Some(draft.revision); - write_generation_ledger(root, &mut ledger)?; - } - } - - Ok(AcknowledgeAssetCanvasCandidateLayersResult { draft }) -} - -pub(crate) fn restore_unacknowledged_candidate_layers_at( - root: &Path, - expected_project_id: &str, - draft_id: &str, -) -> Result, String> { - validate_asset_canvas_project_identity(root, expected_project_id)?; - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; - let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.project_id != manifest.project_id || draft.draft_id != draft_id { - return Err("素材画布候选恢复身份不一致".to_string()); - } - - let mut changed = false; - for record in &draft.generations { - if record.phase != AssetCanvasGenerationStatus::CandidateReady { - continue; - } - let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else { - continue; - }; - if ledger.project_id != draft.project_id - || ledger.draft_id != draft.draft_id - || ledger.generation_id != record.generation_id - || ledger.candidate_layer_acknowledged_revision.is_some() - { - continue; - } - let Some(layer_id) = ledger.candidate_layer_id.as_deref() else { - continue; - }; - if draft - .canvas - .layers - .iter() - .any(|layer| layer.layer_id == layer_id) - { - continue; - } - let AssetCanvasMediaRef::DraftMedia { - media_id, - pixel_width, - pixel_height, - .. - } = ledger - .candidate_media_ref - .as_ref() - .ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())? - else { - continue; - }; - let (x, y, width, height) = ledger - .placeholder - .as_ref() - .map(|placeholder| { - ( - placeholder.x, - placeholder.y, - placeholder.width, - placeholder.height, - ) - }) - .unwrap_or_else(|| { - ( - 6000.0 - f64::from(*pixel_width) / 2.0, - 6000.0 - f64::from(*pixel_height) / 2.0, - f64::from(*pixel_width), - f64::from(*pixel_height), - ) - }); - let z_index = draft - .canvas - .layers - .iter() - .map(|layer| layer.z_index) - .max() - .unwrap_or(0) - .checked_add(1) - .ok_or_else(|| "生成候选图层层序已达到上限".to_string())?; - draft.canvas.layers.push(AssetCanvasLayer { - layer_id: layer_id.to_string(), - resource_id: format!("draft-media:{media_id}"), - title: format!("{} 候选", ledger.asset_name), - media_ref: ledger - .candidate_media_ref - .clone() - .ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?, - x, - y, - width, - height, - original_width: f64::from(*pixel_width), - original_height: f64::from(*pixel_height), - z_index, - group_id: None, - hidden: false, - locked: false, - flip_x: false, - flip_y: false, - }); - draft.canvas.selected_layer_ids = vec![layer_id.to_string()]; - draft.canvas.primary_selected_layer_id = Some(layer_id.to_string()); - changed = true; - } - - if !changed { - return Ok(None); - } - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - Ok(Some(draft)) -} - -fn normalized_request_fingerprint( - input: &GenerateAssetCanvasImageInput, - manifest: &GameCreationAppManifest, - draft: &AssetCanvasDraft, - prompt: &str, - asset_kind: &str, - asset_name: &str, - references: &[String], -) -> Result { - let request = NormalizedGenerationRequest { - project_id: &manifest.project_id, - draft_id: &input.draft_id, - intent: &draft.intent, - source_asset_id: &draft.source_asset_id, - source_layer_id: &input.source_layer_id, - placeholder: &input.placeholder, - intent_id: &input.intent_id, - generation_id: &input.generation_id, - idempotency_key: &input.idempotency_key, - commit_id: &input.commit_id, - commit_idempotency_key: &input.commit_idempotency_key, - expected_host_revision: input.expected_host_revision, - expected_draft_revision: input.expected_draft_revision, - prompt, - aspect_ratio: &input.aspect_ratio, - image_size: &input.image_size, - asset_kind, - asset_name, - reference_resource_ids: references, - }; - serde_json::to_vec(&request) - .map(|bytes| asset_canvas_sha256(&bytes)) - .map_err(|_| "无法建立素材画布生成请求指纹".to_string()) -} - -fn validate_generation_identity_uniqueness( - root: &Path, - input: &GenerateAssetCanvasImageInput, -) -> Result<(), String> { - let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/generations"))?; - let entries = match fs::read_dir(directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(_) => return Err("读取素材画布私有生成账本目录失败".to_string()), - }; - let mut count = 0_usize; - for entry in entries { - let entry = entry.map_err(|_| "读取素材画布私有生成账本失败".to_string())?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - count += 1; - if count > ASSET_CANVAS_MAX_GENERATIONS { - return Err("素材画布私有生成账本数量超过上限".to_string()); - } - let generation_id = path - .file_stem() - .and_then(|value| value.to_str()) - .ok_or_else(|| "素材画布私有生成账本文件名无效".to_string())?; - if generation_id == input.generation_id { - continue; - } - let ledger = read_generation_ledger(root, generation_id)? - .ok_or_else(|| "素材画布私有生成账本读取结果不一致".to_string())?; - if ledger.intent_id == input.intent_id - || ledger.idempotency_key == input.idempotency_key - || ledger.commit_id == input.commit_id - || ledger.commit_idempotency_key == input.commit_idempotency_key - { - return Err("生成 intentId 或幂等身份已绑定到不同 generationId".to_string()); - } - } - Ok(()) -} - -fn validate_and_prepare_ledger( - root: &Path, - input: &GenerateAssetCanvasImageInput, - api_base_url: &str, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result { - for (value, label) in [ - (&input.draft_id, "draftId"), - (&input.intent_id, "intentId"), - (&input.generation_id, "generationId"), - (&input.idempotency_key, "idempotencyKey"), - (&input.commit_id, "commitId"), - (&input.commit_idempotency_key, "commitIdempotencyKey"), - ] { - validate_uuid_v4(value, label)?; - } - validate_safe_revision(input.expected_host_revision, "expectedHostRevision")?; - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - if !matches!( - input.aspect_ratio.as_str(), - "1:1" | "2:3" | "3:2" | "9:16" | "16:9" - ) { - return Err("图片比例无效".to_string()); - } - if !matches!(input.image_size.as_str(), "0.5K" | "1K" | "2K") { - return Err("图片尺寸无效".to_string()); - } - let prompt = input.prompt.trim(); - if prompt.is_empty() || prompt.chars().count() > 32_000 { - return Err("图片提示词必须在 1..=32000 字符内".to_string()); - } - let asset_kind = validate_asset_kind(&input.asset_kind)?; - let asset_name = validate_asset_canvas_name(&input.asset_name)?; - if let Some(source_layer_id) = input.source_layer_id.as_deref() { - validate_plain_component(source_layer_id, "sourceLayerId", 512)?; - } - if let Some(placeholder) = input.placeholder.as_ref() { - validate_asset_canvas_generation_placeholder(placeholder)?; - } - let mut references = normalize_asset_canvas_references(&input.reference_resource_ids)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if let Some(source_layer_id) = input.source_layer_id.as_deref() { - let source_layer = draft - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == source_layer_id) - .ok_or_else(|| "图片生成来源图层不属于当前草稿".to_string())?; - if source_layer.hidden { - return Err("图片生成来源图层不可见".to_string()); - } - } - if draft.intent == AssetCanvasIntent::Refine { - let source = draft - .source_resource_id - .as_ref() - .ok_or_else(|| "精修草稿缺少源资源身份".to_string())?; - references.push(source.clone()); - references = normalize_asset_canvas_references(&references)?; - } - let reference_limit = if draft.intent == AssetCanvasIntent::Refine { - ASSET_CANVAS_GENERATION_REFERENCE_LIMIT - } else { - ASSET_CANVAS_GENERATION_REFERENCE_LIMIT - }; - if references.len() > reference_limit { - return Err(format!("图片生成最多支持 {reference_limit} 个参考资源")); - } - let fingerprint = normalized_request_fingerprint( - input, - &manifest, - &draft, - prompt, - &asset_kind, - &asset_name, - &references, - )?; - if let Some(ledger) = read_generation_ledger(root, &input.generation_id)? { - if ledger.request_fingerprint != fingerprint - || ledger.project_id != manifest.project_id - || ledger.intent_id != input.intent_id - || ledger.idempotency_key != input.idempotency_key - || ledger.commit_id != input.commit_id - || ledger.commit_idempotency_key != input.commit_idempotency_key - { - return Err("generationId、intentId 或幂等键已绑定到不同生成请求".to_string()); - } - return Ok(ledger); - } - validate_generation_identity_uniqueness(root, input)?; - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - if current_revision.revision != input.expected_host_revision { - return Err("project-revision-conflict".to_string()); - } - if draft.revision != input.expected_draft_revision { - return Err("draft-revision-conflict".to_string()); - } - if !matches!( - draft.status, - AssetCanvasDraftStatus::Editing | AssetCanvasDraftStatus::Generating - ) { - return Err("素材画布草稿当前状态不允许生成".to_string()); - } - let now = asset_canvas_now(); - let mut ledger = AssetCanvasGenerationLedger { - schema_version: ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION.to_string(), - request_fingerprint: fingerprint, - project_id: manifest.project_id, - draft_id: draft.draft_id, - intent: draft.intent, - source_asset_id: draft.source_asset_id, - source_resource_id: draft.source_resource_id, - source_layer_id: input.source_layer_id.clone(), - placeholder: input.placeholder.clone(), - intent_id: input.intent_id.clone(), - generation_id: input.generation_id.clone(), - idempotency_key: input.idempotency_key.clone(), - commit_id: input.commit_id.clone(), - commit_idempotency_key: input.commit_idempotency_key.clone(), - expected_host_revision: input.expected_host_revision, - expected_draft_revision: input.expected_draft_revision, - current_draft_revision: Some(input.expected_draft_revision), - prompt: prompt.to_string(), - aspect_ratio: input.aspect_ratio.clone(), - image_size: input.image_size.clone(), - asset_kind, - asset_name, - requested_reference_resource_ids: references, - reference_states: Vec::new(), - resolved_reference_ids: Vec::new(), - reference_binding_repair_attempted: false, - access_scheme: None, - api_identity_scheme: None, - api_identity_fingerprint: None, - platform_owner_user_id: None, - platform_owner_api_base_url: None, - service_identity_confirmation: None, - canvas_context: None, - endpoint: None, - request_body_sha256: None, - request_body_json: None, - phase: GenerationLedgerPhase::ContextPreparing, - operation_id: None, - poll_after_ms: None, - remote_result: None, - staged_image_token: Uuid::new_v4().to_string(), - staged_draft_revision: None, - candidate_media_ref: None, - candidate_layer_id: None, - candidate_layer_acknowledged_revision: None, - commit_result: None, - error_code: None, - archived_at: None, - created_at: now, - updated_at: now, - }; - if api_base_url.is_empty() && platform_session.is_none() { - write_generation_ledger(root, &mut ledger)?; - } else { - initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; - } - Ok(ledger) -} - -fn is_registered_editor_reference_id(value: &str) -> bool { - value.starts_with("editor-resource-") || value.starts_with("editor-asset-") -} - -fn reference_material_at( - root: &Path, - ledger: &AssetCanvasGenerationLedger, - resource_id: &str, -) -> Result { - let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; - let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if let Some(asset) = manifest.assets.iter().find(|asset| { - asset.id == resource_id - || resource_id.strip_prefix("asset:") == Some(asset.id.as_str()) - || asset_canvas_source_resource_id(asset) == resource_id - || asset.source.resource_id.as_deref() == Some(resource_id) - }) { - let (bytes, width, height) = open_and_validate_image_file( - &resolve_local_project_path(root, &asset.local_path)?, - &asset.media_type, - None, - )?; - let extension = media_extension(&asset.media_type)?; - let sha256 = asset_canvas_sha256(&bytes); - return Ok(ReferenceMaterial { - stable_reference: None, - local_asset_id: Some(asset.id.clone()), - asset_kind: Some(asset.kind.clone()), - bytes: Some(bytes), - media_type: Some(asset.media_type.clone()), - file_name: Some(format!("reference-{sha256}.{extension}")), - sha256: Some(sha256), - width: Some(width), - height: Some(height), - }); - } - let layer = draft - .canvas - .layers - .iter() - .find(|layer| layer.resource_id == resource_id) - .ok_or_else(|| "参考资源不属于当前草稿或项目 manifest".to_string())?; - let (relative_path, media_type, expected_sha256) = - draft_media_relative_path(&draft.draft_id, &layer.media_ref)?; - let (bytes, width, height) = open_and_validate_image_file( - &resolve_local_project_path(root, &relative_path)?, - &media_type, - expected_sha256.as_deref(), - )?; - let extension = media_extension(&media_type)?; - let sha256 = asset_canvas_sha256(&bytes); - Ok(ReferenceMaterial { - stable_reference: None, - local_asset_id: None, - asset_kind: None, - bytes: Some(bytes), - media_type: Some(media_type), - file_name: Some(format!("reference-{sha256}.{extension}")), - sha256: Some(sha256), - width: Some(width), - height: Some(height), - }) -} - -async fn try_confirm_uploaded_reference( - client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - bucket: &str, - object_key: &str, - material: &ReferenceMaterial, - asset_kind: &str, -) -> Result, ReferencePreparationError> { - let endpoint = resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm"); - let response = crate::http_client::with_agc_main_site_marker(authorize_canvas_request( - client - .post(format!("{api_base_url}{endpoint}")) - .json(&serde_json::json!({ - "bucket": bucket, - "objectKey": object_key, - "contentType": material.media_type, - "contentLength": material.bytes.as_ref().map(Vec::len), - "contentHash": material.sha256, - "assetKind": editor_reference_object_asset_kind(asset_kind), - "accessPolicy": "private", - })), - api_mode, - )) - .send() - .await - .map_err(|_| ReferencePreparationError::ConfirmFailed)?; - if matches!(response.status(), reqwest::StatusCode::UNAUTHORIZED) { - return Err(ReferencePreparationError::AuthenticationRequired); - } - if response.status() == reqwest::StatusCode::FORBIDDEN { - return Err(ReferencePreparationError::PermissionDenied); - } - if !response.status().is_success() { - return Ok(None); - } - let payload = response - .json::() - .await - .map_err(|_| ReferencePreparationError::ConfirmFailed)?; - let asset_object = external_editor_response_data(&payload) - .get("assetObject") - .or_else(|| payload.pointer("/data/assetObject")) - .unwrap_or(&serde_json::Value::Null); - let confirmed_object_key = json_string_field(asset_object, "objectKey") - .ok_or(ReferencePreparationError::ConfirmFailed)?; - if confirmed_object_key != object_key { - return Err(ReferencePreparationError::ConfirmFailed); - } - json_string_field(asset_object, "assetObjectId") - .map(Some) - .ok_or(ReferencePreparationError::ConfirmFailed) -} - -async fn request_upload_ticket( - client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - ledger: &AssetCanvasGenerationLedger, - material: &ReferenceMaterial, -) -> Result { - let endpoint = - resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets"); - let payload = external_editor_json_request( - authorize_canvas_request( - client - .post(format!("{api_base_url}{endpoint}")) - .json(&serde_json::json!({ - "legacyPrefix": ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX, - "pathSegments": [ - "editor", - ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE, - ledger.project_id.as_str(), - material.local_asset_id.as_deref().unwrap_or(ledger.draft_id.as_str()), - material.sha256.as_deref().unwrap_or(ledger.generation_id.as_str()) - ], - "fileName": material.file_name, - "contentType": material.media_type, - "access": "private", - "maxSizeBytes": material.bytes.as_ref().map(Vec::len), - "successActionStatus": 204, - })), - api_mode, - ), - "创建参考资源上传凭证", - ) - .await - .map_err(|error| { - ReferencePreparationError::from_http_error(&error, ReferencePreparationError::TicketFailed) - })?; - let upload = external_editor_response_data(&payload) - .get("upload") - .or_else(|| payload.pointer("/data/upload")) - .ok_or(ReferencePreparationError::TicketFailed)?; - let host = json_string_field(upload, "host") - .or_else(|| json_string_field(upload, "endpoint")) - .ok_or(ReferencePreparationError::TicketFailed)?; - let bucket = - json_string_field(upload, "bucket").ok_or(ReferencePreparationError::TicketFailed)?; - let object_key = - json_string_field(upload, "objectKey").ok_or(ReferencePreparationError::TicketFailed)?; - let success_action_status = upload - .get("successActionStatus") - .and_then(serde_json::Value::as_u64) - .and_then(|value| u16::try_from(value).ok()) - .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or(ReferencePreparationError::TicketFailed)?; - let max_size_bytes = upload - .get("maxSizeBytes") - .and_then(serde_json::Value::as_u64) - .ok_or(ReferencePreparationError::TicketFailed)?; - if material - .bytes - .as_ref() - .is_some_and(|bytes| bytes.len() as u64 > max_size_bytes) - { - return Err(ReferencePreparationError::TicketFailed); - } - let form_fields = upload - .get("formFields") - .and_then(serde_json::Value::as_object) - .ok_or(ReferencePreparationError::TicketFailed)? - .iter() - .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string()))) - .collect::>(); - if form_fields.is_empty() { - return Err(ReferencePreparationError::TicketFailed); - } - Ok(PrivateUploadTicket { - host, - bucket, - object_key, - success_action_status, - form_fields, - }) -} - -async fn upload_reference( - ticket: &PrivateUploadTicket, - material: &ReferenceMaterial, - api_base_url: &str, -) -> Result<(), ReferencePreparationError> { - let bytes = material - .bytes - .as_ref() - .ok_or(ReferencePreparationError::ObjectUploadFailed)?; - let media_type = material - .media_type - .as_deref() - .ok_or(ReferencePreparationError::ObjectUploadFailed)?; - let file_name = material - .file_name - .as_deref() - .ok_or(ReferencePreparationError::ObjectUploadFailed)?; - let upload_url = validate_external_asset_download_url(&ticket.host, api_base_url, true) - .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; - let client = build_external_asset_download_client(&upload_url, api_base_url, true) - .await - .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; - let mut form = Form::new(); - for (key, value) in &ticket.form_fields { - form = form.text(key.clone(), value.clone()); - } - let part = Part::bytes(bytes.clone()) - .file_name(file_name.to_string()) - .mime_str(media_type) - .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; - let response = client - .post(upload_url) - .multipart(form.part("file", part)) - .send() - .await - .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; - if response.status().as_u16() != ticket.success_action_status { - return Err(ReferencePreparationError::ObjectUploadFailed); - } - Ok(()) -} - -fn editor_reference_object_asset_kind(asset_kind: &str) -> &'static str { - editor_reference_asset_kind(asset_kind).unwrap_or("image") -} - -fn editor_reference_asset_kind(asset_kind: &str) -> Option<&'static str> { - match asset_kind.trim() { - "character" | "character-art" => Some("character"), - "icon" => Some("icon"), - "icon-spec" => Some("icon-spec"), - "art-spritesheet" | "icon-spritesheet" => Some("icon-spritesheet"), - "ui-prototype" | "ui-design" => Some("ui-design"), - "publication-material" => Some("publication-material"), - "game-background" | "scene" => Some("scene"), - "spec" => Some("spec"), - "illustration" | "game-art" | "image" => None, - _ => None, - } -} - -async fn register_uploaded_image_reference( - client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - ledger: &AssetCanvasGenerationLedger, - material: &ReferenceMaterial, - source_asset_kind: &str, - object_key: &str, - asset_object_id: &str, - resource_binding_key: &str, -) -> Result { - let context = ledger - .canvas_context - .as_ref() - .ok_or(ReferencePreparationError::Internal)?; - let width = material - .width - .ok_or(ReferencePreparationError::MaterialInvalid)?; - let height = material - .height - .ok_or(ReferencePreparationError::MaterialInvalid)?; - let endpoint = format!( - "/api/external/v1/editor/projects/{}/resources", - percent_encode_query_component(&context.project_id) - ); - let mut request_body = serde_json::json!({ - "imageSrc": format!("/{}", object_key.trim_start_matches('/')), - "objectKey": object_key, - "assetObjectId": asset_object_id, - "width": width, - "height": height, - "sourceType": "uploaded", - "generationInputs": { - "source": "game-creator-account-binding", - "operationId": ledger.generation_id, - "localAssetId": material.local_asset_id, - "sourceSha256": material.sha256, - }, - }); - let registered_asset_kind = - editor_reference_asset_kind(source_asset_kind).unwrap_or(source_asset_kind); - request_body - .as_object_mut() - .ok_or(ReferencePreparationError::Internal)? - .insert( - "assetKind".to_string(), - serde_json::json!(registered_asset_kind), - ); - let payload = external_editor_json_request( - authorize_canvas_request( - client - .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route(&endpoint) - )) - .header( - "Idempotency-Key", - format!("game-creator-resource-{resource_binding_key}"), - ) - .json(&request_body), - api_mode, - ), - "登记参考图片项目资源", - ) - .await - .map_err(|error| { - ReferencePreparationError::from_http_error(&error, ReferencePreparationError::ConfirmFailed) - })?; - let data = external_editor_response_data(&payload); - let resource = data.get("resource").unwrap_or(data); - json_string_field(resource, "resourceId").ok_or(ReferencePreparationError::ConfirmFailed) -} - -fn invalidate_reference_binding_for_ledger( - root: &Path, - ledger: &AssetCanvasGenerationLedger, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result { - if ledger.intent != AssetCanvasIntent::Refine { - return Ok(false); - } - let source_resource_id = ledger - .source_resource_id - .as_deref() - .ok_or_else(|| "精修生成缺少源资源 ID".to_string())?; - let material = reference_material_at(root, ledger, source_resource_id)?; - let source_identity = new_external_editor_source_identity( - material - .local_asset_id - .as_deref() - .ok_or_else(|| "精修生成缺少本地源素材 ID".to_string())?, - material - .sha256 - .as_deref() - .ok_or_else(|| "精修生成缺少本地源摘要".to_string())?, - material - .media_type - .as_deref() - .ok_or_else(|| "精修生成缺少本地源媒体类型".to_string())?, - material - .asset_kind - .as_deref() - .ok_or_else(|| "精修生成缺少本地源素材类型".to_string())?, - )?; - let context = ledger - .canvas_context - .as_ref() - .ok_or_else(|| "精修生成缺少 External canvas context".to_string())?; - let access = - ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; - access.validate_frozen_session()?; - let principal = external_editor_binding_principal(&access)?; - invalidate_external_editor_resource_binding_at( - root, - &ledger.project_id, - &principal, - &context.project_id, - &source_identity, - ) -} -async fn ensure_reference_states( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result<(), ReferencePreparationError> { - set_private_phase( - root, - ledger, - GenerationLedgerPhase::ReferencesPreparing, - None, - )?; - let binding_access = - ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session) - .map_err(|error| { - ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) - })?; - validate_reference_binding_access(&binding_access)?; - let principal = external_editor_binding_principal(&binding_access) - .map_err(|_| ReferencePreparationError::Internal)?; - let canvas_context = ledger - .canvas_context - .as_ref() - .ok_or(ReferencePreparationError::Internal)?; - let project_binding = - read_external_editor_project_binding_at(root, &ledger.project_id, &principal) - .map_err(|_| ReferencePreparationError::Internal)? - .ok_or(ReferencePreparationError::Internal)?; - if project_binding.remote_project_id != canvas_context.project_id - || project_binding.remote_asset_folder_id != canvas_context.asset_folder_id - { - return Err(ReferencePreparationError::Internal); - } - - let requested = ledger.requested_reference_resource_ids.clone(); - for resource_id in requested { - let material = reference_material_at(root, ledger, &resource_id) - .map_err(|_| ReferencePreparationError::MaterialInvalid)?; - let source_identity = match ( - material.local_asset_id.as_deref(), - material.sha256.as_deref(), - material.media_type.as_deref(), - material.asset_kind.as_deref(), - ) { - (Some(local_asset_id), Some(source_sha256), Some(media_type), Some(asset_kind)) => { - Some( - new_external_editor_source_identity( - local_asset_id, - source_sha256, - media_type, - asset_kind, - ) - .map_err(|_| ReferencePreparationError::MaterialInvalid)?, - ) - } - (None, _, _, None) => None, - _ => return Err(ReferencePreparationError::MaterialInvalid), - }; - let source_asset_kind = material - .asset_kind - .as_deref() - .unwrap_or(ledger.asset_kind.as_str()) - .to_string(); - let index = match ledger - .reference_states - .iter() - .position(|state| state.resource_id == resource_id) - { - Some(index) => index, - None => { - ledger.reference_states.push(PrivateReferenceState { - resource_id: resource_id.clone(), - stable_reference: material.stable_reference.clone(), - asset_object_id: None, - upload_bucket: None, - upload_object_key: None, - upload_completed: material.stable_reference.is_some(), - }); - write_generation_ledger(root, ledger)?; - ledger.reference_states.len() - 1 - } - }; - - let resource_binding_key = source_identity - .as_ref() - .map(|source_identity| { - external_editor_resource_binding_key_sha256( - &ledger.project_id, - &project_binding.binding_key_sha256, - &project_binding.remote_project_id, - source_identity, - ) - .map_err(|_| ReferencePreparationError::Internal) - }) - .transpose()?; - - let _reference_binding_guard = if let Some(source_identity) = source_identity.as_ref() { - if let Some(binding) = read_external_editor_resource_binding_at( - root, - &ledger.project_id, - &principal, - &project_binding.remote_project_id, - source_identity, - ) - .map_err(|_| ReferencePreparationError::Internal)? - { - if install_registered_reference_binding(root, ledger, index, binding)? { - continue; - } - } - // A manifest resourceId and a stable reference left in an older operation ledger are - // only origin provenance. Neither proves that the current account owns the remote - // resource, so a cache miss must fall back to the local formal file. - ledger.reference_states[index].stable_reference = None; - write_generation_ledger(root, ledger)?; - let binding_key = resource_binding_key - .as_deref() - .ok_or(ReferencePreparationError::Internal)?; - let guard = - acquire_reference_binding_singleflight(root, binding_key, &binding_access).await?; - validate_reference_binding_access(&binding_access)?; - if let Some(binding) = read_external_editor_resource_binding_at( - root, - &ledger.project_id, - &principal, - &project_binding.remote_project_id, - source_identity, - ) - .map_err(|_| ReferencePreparationError::Internal)? - { - if install_registered_reference_binding(root, ledger, index, binding)? { - continue; - } - } - Some(guard) - } else { - None - }; - if ledger.reference_states[index].stable_reference.is_some() { - let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); - if !requires_registered_source - || ledger.reference_states[index] - .stable_reference - .as_deref() - .is_some_and(is_registered_editor_reference_id) - { - continue; - } - } - - if let Some(stable_reference) = material.stable_reference.clone() { - ledger.reference_states[index].stable_reference = Some(stable_reference); - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger)?; - continue; - } - if let (Some(bucket), Some(object_key)) = ( - ledger.reference_states[index].upload_bucket.clone(), - ledger.reference_states[index].upload_object_key.clone(), - ) { - let source_asset_kind = material - .asset_kind - .as_deref() - .unwrap_or(ledger.asset_kind.as_str()); - validate_reference_binding_access(&binding_access)?; - let confirmed_asset_object_id = try_confirm_uploaded_reference( - client, - api_base_url, - api_mode, - &bucket, - &object_key, - &material, - source_asset_kind, - ) - .await; - validate_reference_binding_access(&binding_access)?; - let confirmed_asset_object_id = confirmed_asset_object_id?; - if let Some(asset_object_id) = confirmed_asset_object_id { - let stable_reference = if source_identity.is_some() { - validate_reference_binding_access(&binding_access)?; - let registered_reference = register_uploaded_image_reference( - client, - api_base_url, - api_mode, - ledger, - &material, - &source_asset_kind, - &object_key, - &asset_object_id, - resource_binding_key - .as_deref() - .ok_or(ReferencePreparationError::Internal)?, - ) - .await?; - registered_reference - } else { - object_key.clone() - }; - if let Some(source_identity) = source_identity.as_ref() { - let binding = new_external_editor_resource_binding( - &ledger.project_id, - &principal, - &project_binding.remote_project_id, - source_identity, - Some(&stable_reference), - &object_key, - &asset_object_id, - Some( - material - .width - .ok_or(ReferencePreparationError::MaterialInvalid)?, - ), - Some( - material - .height - .ok_or(ReferencePreparationError::MaterialInvalid)?, - ), - unix_timestamp(), - ) - .map_err(|_| ReferencePreparationError::Internal)?; - write_external_editor_resource_binding_at(root, &binding) - .map_err(|_| ReferencePreparationError::Internal)?; - validate_reference_binding_access(&binding_access)?; - } - ledger.reference_states[index].stable_reference = Some(stable_reference); - ledger.reference_states[index].asset_object_id = Some(asset_object_id); - ledger.reference_states[index].upload_bucket = None; - ledger.reference_states[index].upload_object_key = None; - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger)?; - continue; - } - } - validate_reference_binding_access(&binding_access)?; - let ticket = request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await; - validate_reference_binding_access(&binding_access)?; - let ticket = ticket?; - ledger.reference_states[index].upload_bucket = Some(ticket.bucket.clone()); - ledger.reference_states[index].upload_object_key = Some(ticket.object_key.clone()); - ledger.reference_states[index].upload_completed = false; - write_generation_ledger(root, ledger)?; - validate_reference_binding_access(&binding_access)?; - let upload_result = upload_reference(&ticket, &material, api_base_url).await; - validate_reference_binding_access(&binding_access)?; - upload_result?; - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger)?; - let source_asset_kind = material - .asset_kind - .as_deref() - .unwrap_or(ledger.asset_kind.as_str()); - validate_reference_binding_access(&binding_access)?; - let asset_object_id = try_confirm_uploaded_reference( - client, - api_base_url, - api_mode, - &ticket.bucket, - &ticket.object_key, - &material, - source_asset_kind, - ) - .await; - validate_reference_binding_access(&binding_access)?; - let asset_object_id = asset_object_id?.ok_or(ReferencePreparationError::ConfirmFailed)?; - let stable_reference = if source_identity.is_some() { - validate_reference_binding_access(&binding_access)?; - let registered_reference = register_uploaded_image_reference( - client, - api_base_url, - api_mode, - ledger, - &material, - &source_asset_kind, - &ticket.object_key, - &asset_object_id, - resource_binding_key - .as_deref() - .ok_or(ReferencePreparationError::Internal)?, - ) - .await?; - registered_reference - } else { - ticket.object_key.clone() - }; - if let Some(source_identity) = source_identity.as_ref() { - let binding = new_external_editor_resource_binding( - &ledger.project_id, - &principal, - &project_binding.remote_project_id, - source_identity, - Some(&stable_reference), - &ticket.object_key, - &asset_object_id, - Some( - material - .width - .ok_or(ReferencePreparationError::MaterialInvalid)?, - ), - Some( - material - .height - .ok_or(ReferencePreparationError::MaterialInvalid)?, - ), - unix_timestamp(), - ) - .map_err(|_| ReferencePreparationError::Internal)?; - write_external_editor_resource_binding_at(root, &binding) - .map_err(|_| ReferencePreparationError::Internal)?; - validate_reference_binding_access(&binding_access)?; - } - ledger.reference_states[index].stable_reference = Some(stable_reference); - ledger.reference_states[index].asset_object_id = Some(asset_object_id); - ledger.reference_states[index].upload_bucket = None; - ledger.reference_states[index].upload_object_key = None; - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger)?; - } - ledger.resolved_reference_ids = ledger - .reference_states - .iter() - .map(|state| { - state - .stable_reference - .clone() - .ok_or(ReferencePreparationError::ConfirmFailed) - }) - .collect::, _>>()?; - write_generation_ledger(root, ledger).map_err(|_| ReferencePreparationError::ConfirmFailed) -} - -fn external_generation_kind(asset_kind: &str) -> Option<&'static str> { - match asset_kind { - "icon-spec" => Some("spec"), - "character" | "character-art" => Some("character"), - "ui-prototype" | "ui-design" => Some("ui-design"), - "publication-material" => Some("publication-material"), - _ => None, - } -} - -async fn prepare_canvas_generation_context( - root: &Path, - client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result { - let access = - ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; - access.validate_frozen_session()?; - let context = prepare_external_canvas_generation_context(root, client, &access).await; - access.validate_frozen_session()?; - context -} - -fn build_generation_request_snapshot( - ledger: &AssetCanvasGenerationLedger, -) -> Result<(String, String), String> { - let context = ledger - .canvas_context - .as_ref() - .ok_or_else(|| "素材画布生成缺少 External canvas context".to_string())?; - let placeholder = external_canvas_placeholder(&ledger.aspect_ratio); - let mut body = serde_json::Map::new(); - body.insert("prompt".to_string(), serde_json::json!(ledger.prompt)); - body.insert( - "aspectRatio".to_string(), - serde_json::json!(ledger.aspect_ratio), - ); - body.insert( - "imageSize".to_string(), - serde_json::json!(ledger.image_size), - ); - body.insert( - "assetLabel".to_string(), - serde_json::json!(ledger.asset_name), - ); - body.insert( - "projectId".to_string(), - serde_json::json!(context.project_id), - ); - body.insert( - "assetFolderId".to_string(), - serde_json::json!(context.asset_folder_id), - ); - body.insert( - "canvasCompletion".to_string(), - serde_json::json!({ "title": ledger.asset_name, "placeholder": placeholder }), - ); - let endpoint = if ledger.intent == AssetCanvasIntent::Refine { - body.insert( - "generationInputs".to_string(), - serde_json::json!({ - "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, - "operationId": ledger.generation_id, - }), - ); - let source_reference_id = ledger - .source_resource_id - .as_ref() - .and_then(|resource_id| { - ledger - .reference_states - .iter() - .find(|state| state.resource_id == *resource_id) - }) - .and_then(|state| state.stable_reference.clone()) - .ok_or_else(|| "精修生成缺少正式源资源 ID".to_string())?; - body.insert( - "sourceReferenceId".to_string(), - serde_json::json!(source_reference_id), - ); - let additional = ledger - .resolved_reference_ids - .iter() - .filter(|reference| **reference != source_reference_id) - .cloned() - .collect::>(); - body.insert( - "referenceImageSrcs".to_string(), - serde_json::json!(additional), - ); - "/api/external/v1/editor/images/edits" - } else { - body.insert( - "assetKind".to_string(), - serde_json::json!(ledger.asset_kind), - ); - body.insert( - "referenceImageSrcs".to_string(), - serde_json::json!(ledger.resolved_reference_ids), - ); - if let Some(kind) = external_generation_kind(&ledger.asset_kind) { - body.insert("kind".to_string(), serde_json::json!(kind)); - } - "/api/external/v1/editor/images/generations" - }; - let body_json = serde_json::to_string(&serde_json::Value::Object(body)) - .map_err(|_| "无法序列化 External Editor 生成请求".to_string())?; - Ok((endpoint.to_string(), body_json)) -} - -fn is_external_canvas_generation_endpoint(endpoint: &str) -> bool { - matches!( - endpoint, - "/api/external/v1/editor/images/edits" | "/api/external/v1/editor/images/generations" - ) -} - -fn extract_remote_result(generated: &serde_json::Value) -> Result { - if !external_generation_result_has_download_reference(generated) { - return Err("stable-reference-missing".to_string()); - } - let null = serde_json::Value::Null; - let resource = generated - .get("resource") - .filter(|value| value.is_object()) - .unwrap_or(generated); - let asset = generated - .get("asset") - .filter(|value| value.is_object()) - .unwrap_or(&null); - let resource_id = json_string_field(resource, "resourceId") - .or_else(|| json_string_field(generated, "resourceId")) - .ok_or_else(|| "stable-reference-missing".to_string())?; - let object_key = json_string_field(resource, "objectKey") - .or_else(|| json_string_field(generated, "objectKey")) - .ok_or_else(|| "stable-reference-missing".to_string())?; - let asset_object_id = json_string_field(resource, "assetObjectId") - .or_else(|| json_string_field(asset, "assetObjectId")) - .or_else(|| json_string_field(generated, "assetObjectId")) - .ok_or_else(|| "stable-reference-missing".to_string())?; - Ok(PrivateRemoteResult { - resource_id, - object_key, - asset_object_id, - }) -} - -fn committed_execution_from_private_result( - root: &Path, - ledger: &AssetCanvasGenerationLedger, -) -> Result { - let committed = ledger - .commit_result - .as_ref() - .ok_or_else(|| "已完成生成账本缺少本地 commit 回执".to_string())?; - let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; - let _asset = manifest - .assets - .iter() - .find(|asset| asset.id == committed.asset_id) - .ok_or_else(|| "已完成生成账本对应 manifest 资产不存在".to_string())?; - let generation = AssetCanvasGenerationRecord { - generation_id: ledger.generation_id.clone(), - intent_id: ledger.intent_id.clone(), - phase: AssetCanvasGenerationStatus::AssetDurableCommitted, - reference_resource_ids: ledger.requested_reference_resource_ids.clone(), - output_asset_id: Some(committed.asset_id.clone()), - source_layer_id: ledger.source_layer_id.clone(), - placeholder: ledger.placeholder.clone(), - error_code: None, - created_at: ledger.created_at, - updated_at: ledger.updated_at, - idempotency_key: None, - status: None, - prompt: None, - operation_id: None, - output_media_ids: Vec::new(), - }; - Ok(GenerateAssetCanvasImageExecution { - result: GenerateAssetCanvasImageResult { - generation, - images: Vec::new(), - draft: read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?, - commit: Some(AssetCanvasGenerationCommitResult { - resource_id: committed.resource_id.clone(), - asset_id: committed.asset_id.clone(), - project_id: committed.project_id.clone(), - commit_id: committed.commit_id.clone(), - committed_project_revision: committed.committed_project_revision, - draft_revision: ledger - .current_draft_revision - .unwrap_or(committed.draft_revision), - host_revision: committed.host_revision.to_string(), - commit_status: committed.commit_status.clone(), - manifest, - event_id: committed.event_id.clone(), - }), - }, - event: None, - }) -} - -fn private_commit_result_from_execution( - execution: &CommitAssetCanvasExecution, -) -> Result { - match &execution.result { - CommitAssetCanvasResult::Committed { - project_id, - project_revision, - committed_project_revision, - draft_revision, - commit_id, - event_id, - asset, - .. - } => Ok(PrivateCommitResult { - resource_id: asset - .source - .resource_id - .clone() - .unwrap_or_else(|| asset.id.clone()), - asset_id: asset.id.clone(), - project_id: project_id.clone(), - commit_id: commit_id.clone(), - committed_project_revision: *committed_project_revision, - draft_revision: *draft_revision, - host_revision: *project_revision, - commit_status: "committed".to_string(), - event_id: event_id.clone(), - }), - CommitAssetCanvasResult::AlreadyCommitted { - project_id, - project_revision, - committed_project_revision, - draft_revision, - commit_id, - event_id, - asset, - .. - } => Ok(PrivateCommitResult { - resource_id: asset - .source - .resource_id - .clone() - .unwrap_or_else(|| asset.id.clone()), - asset_id: asset.id.clone(), - project_id: project_id.clone(), - commit_id: commit_id.clone(), - committed_project_revision: *committed_project_revision, - draft_revision: *draft_revision, - host_revision: *project_revision, - commit_status: "already-committed".to_string(), - event_id: event_id.clone(), - }), - _ => Err("commit-reconciliation-required".to_string()), - } -} - -fn validate_asset_commit_settlement_evidence( - root: &Path, - generation_ledger: &AssetCanvasGenerationLedger, - committed: &PrivateCommitResult, - manifest: &GameCreationAppManifest, -) -> Result<(), String> { - let invalid = || "待提交结算的生成账本 commit 回执与阶段三事务不一致".to_string(); - if committed.project_id != generation_ledger.project_id - || committed.commit_id != generation_ledger.commit_id - || !matches!( - committed.commit_status.as_str(), - "committed" | "already-committed" - ) - || committed.host_revision < committed.committed_project_revision - { - return Err(invalid()); - } - let commit_ledger = - read_asset_canvas_ledger(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; - let committed_draft_revision = commit_ledger.committed_draft_revision.ok_or_else(invalid)?; - let committed_project_revision = commit_ledger - .committed_project_revision - .ok_or_else(invalid)?; - if commit_ledger.status != AssetCanvasLedgerStatus::Committed - || commit_ledger.project_id != generation_ledger.project_id - || commit_ledger.draft_id != generation_ledger.draft_id - || commit_ledger.commit_id != generation_ledger.commit_id - || commit_ledger.idempotency_key != generation_ledger.commit_idempotency_key - || commit_ledger.expected_project_revision != generation_ledger.expected_host_revision - || committed_project_revision != committed.committed_project_revision - || commit_ledger.expected_project_revision.checked_add(1) - != Some(committed_project_revision) - || committed_draft_revision != committed.draft_revision - || commit_ledger.expected_draft_revision.checked_add(1) != Some(committed_draft_revision) - || commit_ledger.asset_id.as_deref() != Some(committed.asset_id.as_str()) - || commit_ledger.event_id != committed.event_id - { - return Err(invalid()); - } - let journal = - read_asset_canvas_journal(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; - if !matches!( - journal.stage, - AssetCanvasTransactionStage::Committed | AssetCanvasTransactionStage::EventAttempted - ) || journal.project_id != commit_ledger.project_id - || journal.draft_id != commit_ledger.draft_id - || journal.commit_id != commit_ledger.commit_id - || journal.idempotency_key != commit_ledger.idempotency_key - || journal.request_fingerprint != commit_ledger.request_fingerprint - || journal.expected_project_revision != commit_ledger.expected_project_revision - || journal.target_project_revision != committed_project_revision - || journal.expected_draft_revision != commit_ledger.expected_draft_revision - || journal.target_draft_revision != committed_draft_revision - || journal.asset_id != committed.asset_id - || journal.event_id != committed.event_id - { - return Err(invalid()); - } - let mut assets = manifest - .assets - .iter() - .filter(|asset| asset.id == committed.asset_id); - let asset = assets.next().ok_or_else(invalid)?; - if assets.next().is_some() - || asset - .source - .resource_id - .as_deref() - .unwrap_or(asset.id.as_str()) - != committed.resource_id - { - return Err(invalid()); - } - let event = commit_ledger.event_payload.as_ref().ok_or_else(invalid)?; - if event.project_id != generation_ledger.project_id - || event.draft_id != generation_ledger.draft_id - || event.commit_id != generation_ledger.commit_id - || event.idempotency_key != generation_ledger.commit_idempotency_key - || event.event_id != committed.event_id - || event.committed_project_revision != committed_project_revision - || event.asset != *asset - { - return Err(invalid()); - } - Ok(()) -} - -fn finish_asset_commit_settlement_pending_locked( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { - if ledger.phase != GenerationLedgerPhase::AssetCommitSettlementPending { - return Err("素材画布生成账本不在待提交结算阶段".to_string()); - } - let committed = ledger - .commit_result - .clone() - .ok_or_else(|| "待提交结算的生成账本缺少本地 commit 回执".to_string())?; - let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; - validate_asset_commit_settlement_evidence(root, ledger, &committed, &manifest)?; - let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - let existing = draft - .generations - .iter() - .find(|record| record.generation_id == ledger.generation_id) - .map(|record| { - let matches_private_commit = record.intent_id == ledger.intent_id - && record.output_asset_id.as_deref() == Some(committed.asset_id.as_str()) - && record.error_code.is_none(); - if record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted - && !matches_private_commit - { - return Err("公开提交终态与私有生成账本不一致".to_string()); - } - Ok( - (record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted - && matches_private_commit) - .then(|| record.clone()), - ) - }) - .transpose()? - .flatten(); - let (record, draft_revision) = if let Some(record) = existing { - (record, draft.revision) - } else { - let mut projection = ledger.clone(); - projection.phase = GenerationLedgerPhase::AssetDurableCommitted; - upsert_public_generation_record_locked(root, &projection)? - }; - if ledger.staged_draft_revision.is_some() { - let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; - rebind_asset_canvas_staged_image_revision_locked( - root, - &ledger.staged_image_token, - &ledger.project_id, - &ledger.draft_id, - metadata.draft_revision, - draft_revision, - )?; - ledger.staged_draft_revision = Some(draft_revision); - } - ledger.current_draft_revision = Some(draft_revision); - if let Some(commit_result) = ledger.commit_result.as_mut() { - commit_result.draft_revision = draft_revision; - } - ledger.phase = GenerationLedgerPhase::AssetDurableCommitted; - write_generation_ledger(root, ledger)?; - let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - Ok((record, draft)) -} - -fn finish_asset_commit_settlement_pending_at( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), -) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { - let (record, draft) = { - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - finish_asset_commit_settlement_pending_locked(root, ledger)? - }; - emit(generation_progress_event( - ledger, - public_phase_name(&record.phase), - public_progress_value(&record.phase), - record.error_code.clone(), - )); - Ok((record, draft)) -} - -fn candidate_execution_from_private_result( - root: &Path, - ledger: &AssetCanvasGenerationLedger, -) -> Result { - let media_ref = ledger - .candidate_media_ref - .clone() - .ok_or_else(|| "已完成生成账本缺少候选媒体引用".to_string())?; - let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - let generation = draft - .generations - .iter() - .find(|record| record.generation_id == ledger.generation_id) - .cloned() - .ok_or_else(|| "已完成生成账本缺少公开候选记录".to_string())?; - let resource_id = match &media_ref { - AssetCanvasMediaRef::DraftMedia { media_id, .. } => { - format!("draft-media:{media_id}") - } - AssetCanvasMediaRef::ProjectAsset { .. } => { - return Err("生成候选媒体不能引用正式项目资产".to_string()); - } - }; - Ok(GenerateAssetCanvasImageExecution { - result: GenerateAssetCanvasImageResult { - generation, - images: vec![AssetCanvasGeneratedImage { - media_ref, - resource_id, - }], - draft, - commit: None, - }, - event: None, - }) -} - -fn mark_generation_error( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - reconciliation: bool, - error_code: &str, - emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), -) -> Result<(), String> { - mark_generation_error_with_pending_hook(root, ledger, reconciliation, error_code, emit, || { - Ok(()) - }) -} - -fn mark_generation_error_with_pending_hook( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - reconciliation: bool, - error_code: &str, - emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), - after_pending: impl FnOnce() -> Result<(), String>, -) -> Result<(), String> { - // Cancellation is authoritative and must not be overwritten by a late - // network/commit error. The private ledger keeps its last durable phase as - // evidence while all public projection and candidate persistence stop. - let (_, record) = { - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - if pending_failure_settlement_target(&ledger.phase).is_some() { - finish_generation_failure_settlement_pending_locked(root, ledger)? - } else { - let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - validate_generation_draft_active(ledger, &draft)?; - let reconciliation = - reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; - ledger.phase = if reconciliation { - GenerationLedgerPhase::ReconciliationSettlementPending - } else { - GenerationLedgerPhase::FailureSettlementPending - }; - ledger.error_code = Some(error_code.to_string()); - ledger.current_draft_revision = Some(draft.revision); - write_generation_ledger(root, ledger)?; - after_pending()?; - finish_generation_failure_settlement_pending_locked(root, ledger)? - } - }; - emit(generation_progress_event( - ledger, - public_phase_name(&record.phase), - public_progress_value(&record.phase), - record.error_code.clone(), - )); - Ok(()) -} - -fn pending_failure_settlement_target( - phase: &GenerationLedgerPhase, -) -> Option<(GenerationLedgerPhase, &'static str)> { - match phase { - GenerationLedgerPhase::FailureSettlementPending => { - Some((GenerationLedgerPhase::Failed, "failed")) - } - GenerationLedgerPhase::ReconciliationSettlementPending => Some(( - GenerationLedgerPhase::ReconciliationRequired, - "reconciliation-required", - )), - _ => None, - } -} - -fn finish_generation_failure_settlement_pending_locked( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result< - ( - FinalizeAssetCanvasGenerationFailureResult, - AssetCanvasGenerationRecord, - ), - String, -> { - let (target_phase, disposition) = pending_failure_settlement_target(&ledger.phase) - .ok_or_else(|| "素材画布生成账本不在待失败结算阶段".to_string())?; - let error_code = ledger - .error_code - .clone() - .ok_or_else(|| "待结算的素材画布生成账本缺少 errorCode".to_string())?; - let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - validate_generation_draft_active(ledger, &draft)?; - let target_public_phase = - public_phase(&target_phase).expect("failure settlement target phase is public"); - let existing = draft - .generations - .iter() - .find(|record| record.generation_id == ledger.generation_id) - .filter(|record| { - record.intent_id == ledger.intent_id - && record.phase == target_public_phase - && record.error_code.as_deref() == Some(error_code.as_str()) - }) - .cloned(); - let (record, draft_revision) = if let Some(record) = existing { - (record, draft.revision) - } else { - let mut projection = ledger.clone(); - projection.phase = target_phase.clone(); - upsert_public_generation_record_locked(root, &projection)? - }; - if ledger.staged_draft_revision.is_some() { - let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; - rebind_asset_canvas_staged_image_revision_locked( - root, - &ledger.staged_image_token, - &ledger.project_id, - &ledger.draft_id, - metadata.draft_revision, - draft_revision, - )?; - ledger.staged_draft_revision = Some(draft_revision); - } - ledger.current_draft_revision = Some(draft_revision); - ledger.phase = target_phase; - write_generation_ledger(root, ledger)?; - draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())?; - Ok(( - FinalizeAssetCanvasGenerationFailureResult { - disposition: disposition.to_string(), - draft, - }, - record, - )) -} - -fn finish_generation_failure_settlement_pending_at( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), -) -> Result { - let (result, record) = { - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - finish_generation_failure_settlement_pending_locked(root, ledger)? - }; - emit(generation_progress_event( - ledger, - public_phase_name(&record.phase), - public_progress_value(&record.phase), - record.error_code.clone(), - )); - Ok(result) -} - -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 (result, record) = { - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - if pending_failure_settlement_target(&ledger.phase).is_some() { - finish_generation_failure_settlement_pending_locked(root, ledger)? - } else { - let 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::AssetCommitSettlementPending - | 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::ReconciliationSettlementPending - } else { - GenerationLedgerPhase::FailureSettlementPending - }; - ledger.error_code = Some(error_code.to_string()); - ledger.current_draft_revision = Some(expected_draft_revision); - write_generation_ledger(root, ledger)?; - finish_generation_failure_settlement_pending_locked(root, ledger)? - } - }; - emit(generation_progress_event( - ledger, - public_phase_name(&record.phase), - public_progress_value(&record.phase), - record.error_code.clone(), - )); - Ok(result) -} - -fn sanitized_generation_error(code: &str) -> String { - match code { - "configuration-missing" => match editor_api_mode() { - EditorApiMode::PlatformAccount => "陶泥儿登录态缺失,请先登录后再生成图片".to_string(), - EditorApiMode::ExternalDeveloper => { - "External Editor API 配置缺失,未创建任何资源".to_string() - } - }, - "authentication-required" => match editor_api_mode() { - EditorApiMode::PlatformAccount => "陶泥儿登录已失效,请重新登录".to_string(), - EditorApiMode::ExternalDeveloper => { - "External Editor API Key 无效或权限不足,请检查客户端私有配置".to_string() - } - }, - "permission-denied" => editor_api_authorization_error(), - "insufficient-mud-points" => "泥点余额不足,请充值后重试".to_string(), - "platform-service-configuration" => { - "平台图片生成服务暂不可用,请稍后重试或联系管理员".to_string() - } - "invalid-generation-request" => "图片生成请求参数不符合平台合同,未创建资源".to_string(), - "source-reference-invalid" => { - "精修源图片引用无效或已失效,请重新打开资源后重试".to_string() - } - "unsupported-source-kind" => "当前资源类型不支持图片快速编辑,未创建资源".to_string(), - "generation-rejected" => "图片生成请求被平台明确拒绝,未创建资源".to_string(), - "generation-failed" => "图片生成失败,未创建资源".to_string(), - "stable-reference-missing" => "远端生成结果缺少稳定资源引用,未创建本地资源".to_string(), - "reference-material-invalid" => "参考资源读取或校验失败,未提交生成".to_string(), - "reference-ticket-failed" => "参考资源上传凭证申请失败,未提交生成".to_string(), - "reference-object-upload-failed" => "参考资源上传失败,未提交生成".to_string(), - "reference-confirm-failed" => "参考资源对象确认失败,未提交生成".to_string(), - "reference-upload-failed" => "参考资源上传或确认失败,未提交生成".to_string(), - "download-reconciliation-required" => { - "reconciliation-required: 远端图片下载未完成,需要使用原 operation 恢复".to_string() - } - "commit-reconciliation-required" => { - "reconciliation-required: 图片已下载,但正式资产提交需要恢复或对账".to_string() - } - _ => "reconciliation-required: 图片生成结果未知,需要使用原幂等身份恢复,不能重新扣费" - .to_string(), - } -} - -fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String { - if reconciliation && code == "authentication-required" { - return match editor_api_mode() { - EditorApiMode::PlatformAccount => { - "reconciliation-required: 陶泥儿登录态已失效;重新登录后使用原 operation 恢复" - .to_string() - } - EditorApiMode::ExternalDeveloper => { - "reconciliation-required: External Editor API Key 无效或权限不足;修正私有配置后使用原 operation 恢复".to_string() - } - }; - } - sanitized_generation_error(code) -} - -fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool { - matches!( - ledger.phase, - GenerationLedgerPhase::Accepted | GenerationLedgerPhase::Running - ) || (ledger.phase == GenerationLedgerPhase::ReconciliationRequired - && ledger.operation_id.is_some() - && ledger.remote_result.is_none()) -} - -enum CanvasGenerationInitialResponse { - Async(serde_json::Value), -} - -fn classify_canvas_generation_initial_response( - status: reqwest::StatusCode, - payload: &serde_json::Value, -) -> Result { - match classify_external_generation_initial_response(status, payload)? { - ExternalGenerationInitialResponse::AsyncSubmission(submission) => { - Ok(CanvasGenerationInitialResponse::Async(submission)) - } - ExternalGenerationInitialResponse::LegacyCompleted(_) => { - Err("result-unknown: External v1 图片生成必须返回 HTTP 202 与 operationId".to_string()) - } - } -} - -async fn wait_for_canvas_generation_result( - client: &reqwest::Client, - access: &ExternalEditorBindingAccess<'_>, - submission_payload: &serde_json::Value, -) -> Result { - wait_for_external_generation_result_with_access(client, access, submission_payload).await -} - -fn classify_canvas_bad_request(body: &str) -> &'static str { - let payload = serde_json::from_str::(body).ok(); - let details = payload - .as_ref() - .and_then(|payload| { - payload - .pointer("/error/details") - .or_else(|| payload.get("details")) - }) - .and_then(serde_json::Value::as_object); - let provider = details - .and_then(|details| details.get("provider")) - .and_then(serde_json::Value::as_str); - let message = details - .and_then(|details| details.get("message")) - .and_then(serde_json::Value::as_str); - let field = details - .and_then(|details| details.get("field")) - .and_then(serde_json::Value::as_str); - - if provider == Some("editor-image-edit") - && message.is_some_and(|message| message.contains("当前素材类型不支持图片快速编辑")) - { - return "unsupported-source-kind"; - } - if provider == Some("editor-image-edit") && field == Some("sourceReferenceId") { - return "source-reference-invalid"; - } - "invalid-generation-request" -} - -async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static str, bool) { - let status = response.status(); - if matches!(status, reqwest::StatusCode::UNAUTHORIZED) { - return ("authentication-required", false); - } - if status == reqwest::StatusCode::FORBIDDEN { - return ("permission-denied", false); - } - let body = response.text().await.unwrap_or_default(); - if status == reqwest::StatusCode::CONFLICT && body.contains("泥点余额不足") { - return ("insufficient-mud-points", false); - } - if status == reqwest::StatusCode::BAD_REQUEST { - return (classify_canvas_bad_request(&body), false); - } - if status.is_server_error() - && ["配置", "API Key", "供应商", "provider"] - .iter() - .any(|marker| body.contains(marker)) - { - return ("platform-service-configuration", false); - } - if external_generation_submit_rejection_is_definitive(status) - || status == reqwest::StatusCode::CONFLICT - { - return ("generation-rejected", false); - } - ("submit-result-unknown", true) -} - -fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) { - if error.contains("authentication-required") { - return (true, "authentication-required"); - } - if error.contains("permission-denied") { - return (false, "permission-denied"); - } - if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") { - return (false, "insufficient-mud-points"); - } - if error.contains("platform-service-configuration") { - return (false, "platform-service-configuration"); - } - if platform_art_generation_error_needs_reconciliation(error) || error.contains("result-unknown") - { - return (true, "poll-result-unknown"); - } - (false, "generation-failed") -} - -fn preserve_submit_reconciliation( - phase: &GenerationLedgerPhase, - code: &str, - reconciliation: bool, -) -> bool { - reconciliation - || (code == "authentication-required" - && matches!( - phase, - GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired - )) -} - -fn finish_archive_pending_at( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, -) -> Result { - if ledger.phase != GenerationLedgerPhase::ArchivePending { - return Err("素材画布生成账本不在待归档阶段".to_string()); - } - 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 let Some(index) = draft - .generations - .iter() - .position(|record| record.generation_id == ledger.generation_id) - { - draft.generations.remove(index); - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - } - ledger.phase = GenerationLedgerPhase::Archived; - write_generation_ledger(root, ledger)?; - Ok(draft) -} - -async fn reconcile_generation( - root: &Path, - mut ledger: AssetCanvasGenerationLedger, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, - emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), -) -> Result { - if ledger.phase == GenerationLedgerPhase::CandidateReady { - return candidate_execution_from_private_result(root, &ledger); - } - if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { - finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; - return committed_execution_from_private_result(root, &ledger); - } - if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { - return committed_execution_from_private_result(root, &ledger); - } - if ledger.phase == GenerationLedgerPhase::Archived { - return Err("素材画布失败生成已归档".to_string()); - } - if ledger.phase == GenerationLedgerPhase::ArchivePending { - finish_archive_pending_at(root, &mut ledger)?; - return Err("素材画布失败生成已归档".to_string()); - } - if pending_failure_settlement_target(&ledger.phase).is_some() { - let reconciliation = ledger.phase == GenerationLedgerPhase::ReconciliationSettlementPending; - let code = ledger - .error_code - .clone() - .unwrap_or_else(|| "generation-failed".to_string()); - finish_generation_failure_settlement_pending_at(root, &mut ledger, emit)?; - return Err(sanitized_classified_generation_error(&code, reconciliation)); - } - migrate_retryable_credential_failure(root, &mut ledger)?; - if !generation_has_service_identity_evidence(&ledger) { - initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; - } - bind_generation_platform_owner(&ledger, platform_session)?; - if ledger.phase == GenerationLedgerPhase::Failed { - return Err(sanitized_generation_error( - ledger.error_code.as_deref().unwrap_or("generation-failed"), - )); - } - ensure_generation_draft_active(root, &ledger)?; - ensure_frozen_generation_platform_session(platform_session)?; - let configuration_fingerprint = - canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); - match prepare_generation_service_identity( - root, - &mut ledger, - api_base_url, - api_mode, - platform_session, - ) { - Ok(CanvasServiceIdentityDecision::Ready) => {} - Ok(CanvasServiceIdentityDecision::ConfirmationRequired(_)) => { - return Err( - "service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原生成 operation" - .to_string(), - ); - } - Err(error) if error.contains("configuration-changed") => { - mark_generation_error(root, &mut ledger, true, "configuration-changed", emit)?; - return Err(error); - } - Err(error) => return Err(error), - } - if ledger - .endpoint - .as_deref() - .is_some_and(|endpoint| !is_external_canvas_generation_endpoint(endpoint)) - { - mark_generation_error(root, &mut ledger, true, "configuration-changed", emit)?; - return Err( - "reconciliation-required: 历史站内图片生成 operation 不能由 External v1 自动重放" - .to_string(), - ); - } - let client = crate::http_client::agc_main_site_client_builder() - .timeout(Duration::from_secs(60)) - .build() - .map_err(|_| "无法创建图片生成 HTTP 客户端".to_string())?; - let submit_client = crate::http_client::agc_main_site_client_builder() - .timeout(Duration::from_secs(35 * 60)) - .build() - .map_err(|_| "无法创建图片生成提交客户端".to_string())?; - let poll_access = - ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; - - if matches!( - ledger.phase, - GenerationLedgerPhase::ContextPreparing - | GenerationLedgerPhase::ReferencesPreparing - | GenerationLedgerPhase::ReconciliationRequired - ) && ledger.request_body_json.is_none() - { - ledger.api_identity_scheme = - Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some(configuration_fingerprint.clone()); - ledger.service_identity_confirmation = None; - if ledger.canvas_context.is_none() { - let context = match prepare_canvas_generation_context( - root, - &client, - api_base_url, - api_mode, - platform_session, - ) - .await - { - Ok(context) => context, - Err(error) => { - let code = if error.contains("HTTP 401") - || error.contains("HTTP 403") - || error.contains("authentication-required") - || error.contains("登录态已变化") - { - "authentication-required" - } else { - "platform-service-configuration" - }; - if code == "authentication-required" { - mark_generation_error(root, &mut ledger, true, code, emit)?; - } else { - mark_generation_error(root, &mut ledger, false, code, emit)?; - } - return Err(sanitized_generation_error(code)); - } - }; - ledger.canvas_context = Some(context.into()); - write_generation_ledger(root, &mut ledger)?; - } - if let Err(error) = ensure_reference_states( - root, - &mut ledger, - &client, - api_base_url, - api_mode, - platform_session, - ) - .await - { - let code = error.code(); - if code == "authentication-required" { - mark_generation_error(root, &mut ledger, true, code, emit)?; - } else { - mark_generation_error(root, &mut ledger, false, code, emit)?; - } - return Err(sanitized_generation_error(code)); - } - let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?; - ledger.endpoint = Some(endpoint); - ledger.request_body_sha256 = Some(asset_canvas_sha256(body_json.as_bytes())); - ledger.request_body_json = Some(body_json); - set_private_phase(root, &mut ledger, GenerationLedgerPhase::Prepared, None)?; - } - - let generated = if matches!( - ledger.phase, - GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired - ) && ledger.operation_id.is_none() - { - ensure_generation_draft_active(root, &ledger)?; - // Context/reference preparation can contain several awaits. Re-check the - // exact account snapshot after those side effects and immediately before - // the first chargeable/idempotent remote submission. - ensure_frozen_generation_platform_session(platform_session)?; - let endpoint = ledger - .endpoint - .as_deref() - .ok_or_else(|| "私有生成账本缺少 endpoint".to_string())?; - let request_body_json = ledger - .request_body_json - .as_deref() - .ok_or_else(|| "私有生成账本缺少 request body".to_string())?; - let response = match submit_external_generation_request( - &submit_client, - api_base_url, - endpoint, - api_mode.bearer_token(), - &ledger.idempotency_key, - request_body_json, - ) - .await - { - Ok(response) => response, - Err(_) => { - mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; - return Err(sanitized_generation_error("submit-result-unknown")); - } - }; - let post_submit_session = ensure_frozen_generation_platform_session(platform_session); - let status = response.status(); - if !status.is_success() { - post_submit_session?; - let (code, reconciliation) = classify_canvas_submit_error(response).await; - let reconciliation = - preserve_submit_reconciliation(&ledger.phase, code, reconciliation); - if code == "source-reference-invalid" - && ledger.intent == AssetCanvasIntent::Refine - && !ledger.reference_binding_repair_attempted - && !reconciliation - { - ledger.reference_binding_repair_attempted = true; - if invalidate_reference_binding_for_ledger( - root, - &ledger, - api_base_url, - api_mode, - platform_session, - )? { - ledger.endpoint = None; - ledger.request_body_sha256 = None; - ledger.request_body_json = None; - ledger.reference_states.clear(); - ledger.resolved_reference_ids.clear(); - ledger.phase = GenerationLedgerPhase::ContextPreparing; - ledger.error_code = None; - write_generation_ledger(root, &mut ledger)?; - // 持久化修复标记保证这里只会重入一次。Box 仅用于固定 async future 大小, - // 不改变提交与对账边界:只有明确未入队的来源引用拒绝才允许重建后再提交。 - return Box::pin(reconcile_generation( - root, - ledger, - api_base_url, - api_mode, - platform_session, - emit, - )) - .await; - } - } - if code == "authentication-required" && !reconciliation { - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::Prepared, - Some(code), - )?; - } else { - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - } - return Err(sanitized_classified_generation_error(code, reconciliation)); - } - let submission = match response.json::().await { - Ok(value) => value, - Err(_) => { - mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; - return Err(sanitized_generation_error("submit-result-unknown")); - } - }; - let post_body_session = ensure_frozen_generation_platform_session(platform_session); - match classify_canvas_generation_initial_response(status, &submission) { - Ok(CanvasGenerationInitialResponse::Async(submission)) => { - let operation_id = - json_string_field(external_editor_response_data(&submission), "operationId") - .ok_or_else(|| "平台接受响应缺少 operationId".to_string())?; - ledger.operation_id = Some(operation_id); - ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission)); - set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; - publish_public_phase(root, &mut ledger, emit)?; - post_submit_session?; - post_body_session?; - ensure_frozen_generation_platform_session(platform_session)?; - set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; - publish_public_phase(root, &mut ledger, emit)?; - match wait_for_canvas_generation_result(&client, &poll_access, &submission).await { - Ok(result) => { - ensure_frozen_generation_platform_session(platform_session)?; - result - } - Err(error) => { - let (reconciliation, code) = classify_canvas_generation_error(&error); - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - return Err(sanitized_classified_generation_error(code, reconciliation)); - } - } - } - Err(_) => { - mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; - return Err(sanitized_generation_error("submit-result-unknown")); - } - } - } else if should_poll_existing_operation(&ledger) { - let operation_id = ledger - .operation_id - .clone() - .ok_or_else(|| "已接受生成账本缺少 operationId".to_string())?; - let submission = serde_json::json!({ - "operationId": operation_id, - "pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000), - }); - poll_access.validate_frozen_session()?; - set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; - publish_public_phase(root, &mut ledger, emit)?; - match wait_for_canvas_generation_result(&client, &poll_access, &submission).await { - Ok(result) => { - ensure_frozen_generation_platform_session(platform_session)?; - result - } - Err(error) => { - let (reconciliation, code) = classify_canvas_generation_error(&error); - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - return Err(sanitized_classified_generation_error(code, reconciliation)); - } - } - } else { - serde_json::Value::Null - }; - - if matches!( - ledger.phase, - GenerationLedgerPhase::Prepared - | GenerationLedgerPhase::Accepted - | GenerationLedgerPhase::Running - | GenerationLedgerPhase::ReconciliationRequired - ) && !generated.is_null() - { - let remote_result = match extract_remote_result(&generated) { - Ok(result) => result, - Err(_) => { - mark_generation_error(root, &mut ledger, false, "stable-reference-missing", emit)?; - return Err(sanitized_generation_error("stable-reference-missing")); - } - }; - ledger.remote_result = Some(remote_result); - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::RemoteCompleted, - None, - )?; - publish_public_phase(root, &mut ledger, emit)?; - } - - if matches!( - ledger.phase, - GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired - ) && ledger.commit_result.is_none() - { - let staged_exists = - with_frozen_generation_platform_session_commit(platform_session, || { - synchronize_staged_image_revision(root, &mut ledger) - })?; - if !staged_exists { - let remote = ledger - .remote_result - .as_ref() - .ok_or_else(|| "远端完成账本缺少稳定引用".to_string())?; - let download_result = crate::agent::resolve_canvas_resource_download_with_access( - &client, - &poll_access, - &serde_json::json!({ "objectKey": remote.object_key }), - 20 * 1024 * 1024, - ) - .await; - let download = match download_result { - Ok(Some(download)) => download, - _ => { - mark_generation_error( - root, - &mut ledger, - true, - "download-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error( - "download-reconciliation-required", - )); - } - }; - with_frozen_generation_platform_session_commit(platform_session, || { - ensure_generation_draft_active(root, &ledger)?; - let staged = stage_asset_canvas_image_with_token_at( - root, - &StageAssetCanvasImageInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: ledger.project_id.clone(), - draft_id: ledger.draft_id.clone(), - expected_draft_revision: ledger - .current_draft_revision - .unwrap_or(ledger.expected_draft_revision), - media_type: download.media_type, - bytes: download.bytes, - }, - Some(&ledger.staged_image_token), - )?; - if staged.status != "staged" { - mark_generation_error( - root, - &mut ledger, - true, - "download-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error( - "download-reconciliation-required", - )); - } - ledger.current_draft_revision = Some(staged.draft_revision); - ledger.staged_draft_revision = Some(staged.draft_revision); - write_generation_ledger(root, &mut ledger)?; - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::MediaDownloaded, - None, - )?; - publish_public_phase(root, &mut ledger, emit) - })?; - } else { - with_frozen_generation_platform_session_commit(platform_session, || { - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::MediaDownloaded, - None, - )?; - publish_public_phase(root, &mut ledger, emit) - })?; - } - } - - if ledger.phase == GenerationLedgerPhase::MediaDownloaded { - ensure_frozen_generation_platform_session(platform_session)?; - ensure_generation_draft_active(root, &ledger)?; - if !synchronize_staged_image_revision(root, &mut ledger)? { - mark_generation_error( - root, - &mut ledger, - true, - "candidate-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error( - "candidate-reconciliation-required", - )); - } - let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::CandidateReady, - None, - )?; - emit(generation_progress_event( - &ledger, - public_phase_name(&generation.phase), - public_progress_value(&generation.phase), - None, - )); - return Ok(GenerateAssetCanvasImageExecution { - result: GenerateAssetCanvasImageResult { - generation, - images: vec![image], - draft, - commit: None, - }, - event: None, - }); - } - - if ledger.phase == GenerationLedgerPhase::MediaDownloaded { - ensure_frozen_generation_platform_session(platform_session)?; - if !synchronize_staged_image_revision(root, &mut ledger)? { - mark_generation_error( - root, - &mut ledger, - true, - "commit-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error("commit-reconciliation-required")); - } - let commit = with_frozen_generation_platform_session_commit(platform_session, || { - commit_asset_canvas_at( - root, - &CommitAssetCanvasInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: ledger.project_id.clone(), - expected_revision: ledger.expected_host_revision, - expected_draft_revision: ledger - .current_draft_revision - .unwrap_or(ledger.expected_draft_revision), - draft_id: ledger.draft_id.clone(), - commit_id: ledger.commit_id.clone(), - idempotency_key: ledger.commit_idempotency_key.clone(), - intent: ledger.intent.clone(), - source_asset_id: ledger.source_asset_id.clone(), - staged_image_token: ledger.staged_image_token.clone(), - source_layer_id: None, - media_sha256: None, - name: ledger.asset_name.clone(), - asset_kind: ledger.asset_kind.clone(), - reference_resource_ids: ledger.requested_reference_resource_ids.clone(), - generation_provenance: None, - }, - ) - }); - let execution = match commit { - Ok(execution) => execution, - Err(error) if error.contains("authentication-required") => return Err(error), - Err(_) => { - mark_generation_error( - root, - &mut ledger, - true, - "commit-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error("commit-reconciliation-required")); - } - }; - let private_commit = match private_commit_result_from_execution(&execution) { - Ok(commit) => commit, - Err(code) => { - mark_generation_error(root, &mut ledger, true, &code, emit)?; - return Err(sanitized_generation_error(&code)); - } - }; - ledger.current_draft_revision = Some(private_commit.draft_revision); - ledger.commit_result = Some(private_commit); - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::AssetCommitSettlementPending, - None, - )?; - let (generation, committed_draft) = - finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; - let final_draft_revision = ledger - .current_draft_revision - .expect("public commit projection must advance the draft revision"); - let committed = ledger - .commit_result - .as_ref() - .expect("commit result was persisted"); - let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; - return Ok(GenerateAssetCanvasImageExecution { - result: GenerateAssetCanvasImageResult { - generation, - images: Vec::new(), - draft: committed_draft, - commit: Some(AssetCanvasGenerationCommitResult { - resource_id: committed.resource_id.clone(), - asset_id: committed.asset_id.clone(), - project_id: committed.project_id.clone(), - commit_id: committed.commit_id.clone(), - committed_project_revision: committed.committed_project_revision, - draft_revision: final_draft_revision, - host_revision: committed.host_revision.to_string(), - commit_status: committed.commit_status.clone(), - manifest, - event_id: committed.event_id.clone(), - }), - }, - event: execution.event, - }); - } - - committed_execution_from_private_result(root, &ledger) -} - -async fn generation_singleflight_lock( - project_id: &str, - generation_id: &str, -) -> tokio::sync::OwnedMutexGuard<()> { - let locks = - ASSET_CANVAS_GENERATION_LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); - let key = format!("{project_id}:{generation_id}"); - let lock = { - let mut locks = locks.lock().await; - locks - .entry(key) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() - }; - lock.lock_owned().await -} - -pub(crate) async fn generate_asset_canvas_image_at( - root: &Path, - input: &GenerateAssetCanvasImageInput, - mut emit: impl FnMut(AssetCanvasGenerationProgressEvent) + Send, -) -> Result { - enforce_project_permission_policy(root, "canvas.asset_generate")?; - let _guard = - generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; - let (api_base_url, api_key, platform_session) = - match resolve_canvas_sync_api_credentials(None, None) { - Ok(value) => value, - Err(_) => { - let mut ledger = validate_and_prepare_ledger(root, input, "", None)?; - let code = "configuration-missing"; - let mut no_op_emit = |_event: AssetCanvasGenerationProgressEvent| {}; - mark_generation_error(root, &mut ledger, false, code, &mut no_op_emit)?; - return Err(sanitized_generation_error(code)); - } - }; - let ledger = - validate_and_prepare_ledger(root, input, &api_base_url, platform_session.as_ref())?; - let api_mode = CanvasGenerationApiMode { api_key }; - reconcile_generation( - root, - ledger, - &api_base_url, - &api_mode, - platform_session.as_ref(), - &mut emit, - ) - .await -} - -pub(crate) async fn finalize_asset_canvas_generation_failure_at( - root: &Path, - input: &FinalizeAssetCanvasGenerationFailureInput, -) -> Result { - enforce_project_permission_policy(root, "canvas.asset_generate")?; - validate_uuid_v4(&input.draft_id, "draftId")?; - validate_uuid_v4(&input.generation_id, "generationId")?; - validate_uuid_v4(&input.intent_id, "intentId")?; - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - validate_plain_component(&input.error_code, "errorCode", 128)?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _generation_guard = - generation_singleflight_lock(&manifest.project_id, &input.generation_id).await; - - if let Some(mut ledger) = read_generation_ledger(root, &input.generation_id)? { - if ledger.project_id != manifest.project_id - || ledger.draft_id != input.draft_id - || ledger.generation_id != input.generation_id - || ledger.intent_id != input.intent_id - { - return Err("生成失败结算对应的账本身份无效".to_string()); - } - 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)?; - let mut 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 Some(index) = draft - .generations - .iter() - .position(|record| record.generation_id == input.generation_id) - else { - return Ok(FinalizeAssetCanvasGenerationFailureResult { - disposition: "not-started".to_string(), - draft, - }); - }; - let record = &draft.generations[index]; - if record.intent_id != input.intent_id { - return Err("生成失败结算对应的公开记录身份无效".to_string()); - } - if !matches!( - record.phase, - AssetCanvasGenerationStatus::GenerationAccepted - | AssetCanvasGenerationStatus::GenerationRunning - ) { - return Err("没有私有账本的生成记录已进入不可降级终态".to_string()); - } - draft.generations.remove(index); - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.status = AssetCanvasDraftStatus::Editing; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - Ok(FinalizeAssetCanvasGenerationFailureResult { - disposition: "not-started".to_string(), - draft, - }) -} - -pub(crate) async fn archive_failed_asset_canvas_generation_at( - root: &Path, - input: &ArchiveAssetCanvasGenerationInput, -) -> Result { - enforce_project_permission_policy(root, "canvas.asset_generate")?; - validate_uuid_v4(&input.draft_id, "draftId")?; - validate_uuid_v4(&input.generation_id, "generationId")?; - validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; - let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let _generation_guard = - generation_singleflight_lock(&manifest.project_id, &input.generation_id).await; - let mut ledger = read_generation_ledger(root, &input.generation_id)? - .ok_or_else(|| "待归档的素材画布生成账本不存在".to_string())?; - if ledger.project_id != manifest.project_id - || ledger.draft_id != input.draft_id - || ledger.generation_id != input.generation_id - { - return Err("待归档的素材画布生成账本身份无效".to_string()); - } - - let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? - .ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?; - if draft.project_id != ledger.project_id - || draft.draft_id != ledger.draft_id - || draft.source_asset_id != ledger.source_asset_id - || draft.source_resource_id != ledger.source_resource_id - { - return Err("待归档的素材画布生成账本与草稿身份不一致".to_string()); - } - let generation_index = draft - .generations - .iter() - .position(|record| record.generation_id == ledger.generation_id); - if ledger.phase == GenerationLedgerPhase::Archived && generation_index.is_none() { - return Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id, - phase: GenerationLedgerPhase::Archived.as_str().to_string(), - archived_at: ledger - .archived_at - .ok_or_else(|| "已归档的素材画布生成账本缺少 archivedAt".to_string())?, - draft, - }); - } - if ledger.phase == GenerationLedgerPhase::ArchivePending && generation_index.is_none() { - ledger.phase = GenerationLedgerPhase::Archived; - write_generation_ledger(root, &mut ledger)?; - return Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id, - phase: GenerationLedgerPhase::Archived.as_str().to_string(), - archived_at: ledger - .archived_at - .ok_or_else(|| "待归档的素材画布生成账本缺少 archivedAt".to_string())?, - draft, - }); - } - let generation_index = - generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; - if draft.revision != input.expected_draft_revision { - return Err("draft-revision-conflict".to_string()); - } - let public_generation = &draft.generations[generation_index]; - if public_generation.intent_id != ledger.intent_id { - return Err("待归档的素材画布公开生成记录身份无效".to_string()); - } - match ledger.phase { - GenerationLedgerPhase::Failed => { - if public_generation.phase != AssetCanvasGenerationStatus::Failed { - return Err("只有公开状态明确失败的素材画布生成可以归档".to_string()); - } - ledger.phase = GenerationLedgerPhase::ArchivePending; - ledger.archived_at = Some(asset_canvas_now()); - write_generation_ledger(root, &mut ledger)?; - } - GenerationLedgerPhase::ArchivePending => {} - GenerationLedgerPhase::Archived => {} - GenerationLedgerPhase::ReconciliationRequired => { - return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string()); - } - _ => return Err("只有明确失败的素材画布生成可以归档".to_string()), - } - - let archived_at = ledger - .archived_at - .ok_or_else(|| "已归档的素材画布生成账本缺少 archivedAt".to_string())?; - draft.generations.remove(generation_index); - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - if ledger.phase == GenerationLedgerPhase::ArchivePending { - ledger.phase = GenerationLedgerPhase::Archived; - write_generation_ledger(root, &mut ledger)?; - } - Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id, - phase: ledger.phase.as_str().to_string(), - archived_at, - draft, - }) -} - -pub(crate) async fn recover_asset_canvas_generations_at( - root: &Path, - input: &RecoverAssetCanvasGenerationsInput, - mut emit: impl FnMut(AssetCanvasGenerationProgressEvent) + Send, -) -> Result { - enforce_project_permission_policy(root, "canvas.asset_generate")?; - validate_uuid_v4(&input.draft_id, "draftId")?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/generations"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(RecoverAssetCanvasGenerationsExecution { - result: RecoverAssetCanvasGenerationsResult { - resumed_generation_ids: Vec::new(), - service_identity_confirmations: Vec::new(), - }, - events: Vec::new(), - }); - } - Err(_) => return Err("读取素材画布私有生成账本目录失败".to_string()), - }; - let mut generation_ids = Vec::new(); - for entry in entries.take(ASSET_CANVAS_MAX_GENERATIONS + 1) { - let entry = entry.map_err(|_| "读取素材画布私有生成账本失败".to_string())?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let Some(generation_id) = path.file_stem().and_then(|value| value.to_str()) else { - continue; - }; - validate_uuid_v4(generation_id, "generationId")?; - generation_ids.push(generation_id.to_string()); - } - if generation_ids.len() > ASSET_CANVAS_MAX_GENERATIONS { - return Err("素材画布私有生成账本数量超过上限".to_string()); - } - generation_ids.sort(); - if generation_ids.is_empty() { - return Ok(RecoverAssetCanvasGenerationsExecution { - result: RecoverAssetCanvasGenerationsResult { - resumed_generation_ids: Vec::new(), - service_identity_confirmations: Vec::new(), - }, - events: Vec::new(), - }); - } - let draft_status = { - let _lock = acquire_asset_canvas_draft_lock(root)?; - read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)? - .ok_or_else(|| "素材画布草稿不存在".to_string())? - .status - }; - if draft_status == AssetCanvasDraftStatus::Cancelled { - return Ok(RecoverAssetCanvasGenerationsExecution { - result: RecoverAssetCanvasGenerationsResult { - resumed_generation_ids: Vec::new(), - service_identity_confirmations: Vec::new(), - }, - events: Vec::new(), - }); - } - let mut recoverable_generation_ids = Vec::new(); - for generation_id in generation_ids { - let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { - continue; - }; - if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id { - continue; - } - if ledger.phase == GenerationLedgerPhase::ArchivePending { - let _guard = - generation_singleflight_lock(&input.expected_project_id, &generation_id).await; - let _ = finish_archive_pending_at(root, &mut ledger); - continue; - } - if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { - let _guard = - generation_singleflight_lock(&input.expected_project_id, &generation_id).await; - finish_asset_commit_settlement_pending_at(root, &mut ledger, &mut emit)?; - continue; - } - if pending_failure_settlement_target(&ledger.phase).is_some() { - let _guard = - generation_singleflight_lock(&input.expected_project_id, &generation_id).await; - finish_generation_failure_settlement_pending_at(root, &mut ledger, &mut emit)?; - continue; - } - if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted - && (ledger.phase != GenerationLedgerPhase::Failed - || retryable_credential_failure_phase(&ledger).is_some()) - { - recoverable_generation_ids.push(generation_id); - } - } - if recoverable_generation_ids.is_empty() { - restore_unacknowledged_candidate_layers_at( - root, - &input.expected_project_id, - &input.draft_id, - )?; - return Ok(RecoverAssetCanvasGenerationsExecution { - result: RecoverAssetCanvasGenerationsResult { - resumed_generation_ids: Vec::new(), - service_identity_confirmations: Vec::new(), - }, - events: Vec::new(), - }); - } - restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?; - let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) - .map_err(|_| sanitized_generation_error("configuration-missing"))?; - let api_mode = CanvasGenerationApiMode { api_key }; - let mut resumed = Vec::new(); - let mut service_identity_confirmations = Vec::new(); - let mut events = Vec::new(); - for generation_id in recoverable_generation_ids { - let _guard = generation_singleflight_lock(&input.expected_project_id, &generation_id).await; - let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { - continue; - }; - if bind_generation_platform_owner(&ledger, platform_session.as_ref()).is_err() { - continue; - } - migrate_retryable_credential_failure(root, &mut ledger)?; - if ledger.project_id != input.expected_project_id - || ledger.draft_id != input.draft_id - || matches!( - ledger.phase, - GenerationLedgerPhase::CandidateReady - | GenerationLedgerPhase::AssetDurableCommitted - | GenerationLedgerPhase::Failed - | GenerationLedgerPhase::Archived - ) - { - continue; - } - match prepare_generation_service_identity( - root, - &mut ledger, - &api_base_url, - &api_mode, - platform_session.as_ref(), - ) { - Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { - service_identity_confirmations.push(confirmation); - continue; - } - Ok(CanvasServiceIdentityDecision::Ready) => {} - Err(_) => {} - } - resumed.push(generation_id.clone()); - match reconcile_generation( - root, - ledger, - &api_base_url, - &api_mode, - platform_session.as_ref(), - &mut emit, - ) - .await - { - Ok(execution) => { - if let Some(event) = execution.event { - events.push(event); - } - } - Err(_) => {} - } - } - Ok(RecoverAssetCanvasGenerationsExecution { - result: RecoverAssetCanvasGenerationsResult { - resumed_generation_ids: resumed, - service_identity_confirmations, - }, - events, - }) -} - -pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( - root: &Path, - input: &ConfirmAssetCanvasGenerationServiceIdentityInput, -) -> Result { - enforce_project_permission_policy(root, "canvas.asset_generate")?; - validate_uuid_v4(&input.draft_id, "draftId")?; - validate_uuid_v4(&input.generation_id, "generationId")?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - if input.challenge.len() < 32 - || input.challenge.len() > 128 - || input.challenge.chars().any(char::is_control) - || input.operation_id.as_ref().is_some_and(|value| { - value.trim().is_empty() || value.len() > 512 || value.chars().any(char::is_control) - }) - { - return Err("素材画布服务身份确认参数无效".to_string()); - } - - let _generation_guard = - generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; - let _project_lock = - acquire_project_write_lock(root, "asset-canvas.generation-service-identity")?; - validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let (api_base_url, api_mode, platform_session) = resolve_generation_api_mode() - .map_err(|_| sanitized_generation_error("configuration-missing"))?; - with_frozen_generation_platform_session_commit(platform_session.as_ref(), || { - confirm_asset_canvas_generation_service_identity_under_lease( - root, - input, - &api_base_url, - &api_mode, - platform_session.as_ref(), - ) - }) -} - -fn confirm_asset_canvas_generation_service_identity_under_lease( - root: &Path, - input: &ConfirmAssetCanvasGenerationServiceIdentityInput, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - platform_session: Option<&PlatformSessionSnapshot>, -) -> Result { - let service_fingerprint = - canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); - let service_origin = platform_art_generation_external_service_origin(api_base_url)?; - let mut ledger = read_generation_ledger(root, &input.generation_id)? - .ok_or_else(|| "素材画布服务身份确认对应的生成账本不存在".to_string())?; - if ledger.project_id != input.expected_project_id - || ledger.draft_id != input.draft_id - || ledger.generation_id != input.generation_id - || ledger.operation_id != input.operation_id - { - return Err("素材画布服务身份确认对应的 operation 身份已变化".to_string()); - } - validate_generation_service_identity_owner(&ledger, platform_session)?; - let confirmation = ledger - .service_identity_confirmation - .clone() - .ok_or_else(|| "素材画布服务身份确认挑战不存在或已失效".to_string())?; - if confirmation.challenge != input.challenge - || confirmation.expires_at <= asset_canvas_now() - || confirmation.service_fingerprint != service_fingerprint - || confirmation.ledger_snapshot_sha256 - != generation_service_identity_snapshot_sha256(&ledger)? - { - return Err("素材画布服务身份确认挑战已过期或上下文已变化".to_string()); - } - if !matches!( - classify_platform_art_generation_service_identity( - ledger.api_identity_scheme.as_deref(), - ledger.api_identity_fingerprint.as_deref(), - api_base_url, - api_mode.bearer_token(), - platform_session, - ), - PlatformArtGenerationServiceIdentityMatch::LegacyUnverified - | PlatformArtGenerationServiceIdentityMatch::Unbound - ) { - return Err("素材画布服务身份确认目标不再是待确认旧账本".to_string()); - } - - ledger.api_identity_scheme = Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some(service_fingerprint); - ledger.service_identity_confirmation = None; - write_generation_ledger(root, &mut ledger)?; - Ok(ConfirmAssetCanvasGenerationServiceIdentityResult { - generation_id: ledger.generation_id, - operation_id: ledger.operation_id, - operation_state: ledger.phase.as_str().to_string(), - service_origin, - identity_scheme: PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; - use std::future::Future; - use std::io::{Cursor, Read, Write}; - use std::net::{TcpListener, TcpStream}; - use std::sync::mpsc; - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }; - - async fn with_test_external_editor_credentials( - api_base_url: &str, - api_key: &str, - operation: impl Future, - ) -> T { - crate::assets::with_external_editor_api_credentials( - crate::assets::external_editor_api_credentials_for_test( - api_base_url.to_string(), - api_key.to_string(), - ), - operation, - ) - .await - } - - fn write_test_external_editor_project_binding( - root: &Path, - local_project_id: &str, - api_base_url: &str, - bearer_token: &str, - platform_session: Option<&PlatformSessionSnapshot>, - remote_project_id: &str, - remote_asset_folder_id: &str, - ) { - let access = ExternalEditorBindingAccess::new(api_base_url, bearer_token, platform_session) - .expect("build test External Editor binding access"); - let principal = external_editor_binding_principal(&access) - .expect("build test External Editor principal"); - let binding = new_external_editor_project_binding( - local_project_id, - &principal, - remote_project_id, - remote_asset_folder_id, - unix_timestamp(), - ) - .expect("build test External Editor project binding"); - write_external_editor_project_binding_at(root, &binding) - .expect("persist test External Editor project binding"); - } - - fn test_png_with_color(color: [u8; 4]) -> Vec { - let image = RgbaImage::from_pixel(4, 3, Rgba(color)); - let mut output = Cursor::new(Vec::new()); - DynamicImage::ImageRgba8(image) - .write_to(&mut output, ImageFormat::Png) - .expect("encode generation PNG fixture"); - output.into_inner() - } - - fn test_png() -> Vec { - test_png_with_color([21, 87, 180, 255]) - } - - fn read_http_request(stream: &mut TcpStream) -> String { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .expect("set generation fixture timeout"); - let mut bytes = Vec::new(); - let mut buffer = [0_u8; 4096]; - let (header_end, content_length) = loop { - let read = stream.read(&mut buffer).expect("read generation request"); - assert!(read > 0, "generation request closed before headers"); - bytes.extend_from_slice(&buffer[..read]); - let Some(header_end) = bytes.windows(4).position(|value| value == b"\r\n\r\n") else { - continue; - }; - let header_text = String::from_utf8_lossy(&bytes[..header_end]); - let content_length = header_text - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - break (header_end + 4, content_length); - }; - while bytes.len() < header_end + content_length { - let read = stream.read(&mut buffer).expect("read generation body"); - assert!(read > 0, "generation request closed before body"); - bytes.extend_from_slice(&buffer[..read]); - } - String::from_utf8_lossy(&bytes).into_owned() - } - - fn accept_generation_fixture_connection( - listener: &TcpListener, - fixture: &str, - request_index: usize, - ) -> TcpStream { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - match listener.accept() { - Ok((stream, _)) => { - stream - .set_nonblocking(false) - .expect("restore generation fixture stream blocking mode"); - return stream; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - std::time::Instant::now() < deadline, - "timed out waiting for {fixture} request {request_index}" - ); - std::thread::sleep(Duration::from_millis(5)); - } - Err(error) => panic!("accept {fixture} request {request_index}: {error}"), - } - } - } - - fn write_json(stream: &mut TcpStream, status: &str, body: serde_json::Value) { - let body = body.to_string(); - write!( - stream, - "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len(), - ) - .expect("write generation JSON response"); - } - - fn write_png(stream: &mut TcpStream, png: &[u8]) { - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - png.len(), - ) - .and_then(|_| stream.write_all(png)) - .expect("write generation PNG response"); - } - - fn write_empty_response(stream: &mut TcpStream, status: &str) { - write!( - stream, - "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .expect("write empty generation fixture response"); - } - - fn create_generation_fixture( - project_id: &str, - project_name: &str, - ) -> (tempfile::TempDir, AssetCanvasDraft) { - let directory = tempfile::tempdir().expect("create generation fixture"); - init_local_game_project_at(directory.path(), project_id, project_name) - .expect("initialize generation project"); - let draft_id = Uuid::new_v4().to_string(); - let draft = create_asset_canvas_draft_at( - directory.path(), - &CreateAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id, - intent: AssetCanvasIntent::Create, - source_asset_id: None, - }, - ) - .expect("create generation draft") - .draft; - (directory, draft) - } - - fn create_refine_generation_fixture( - project_id: &str, - project_name: &str, - ) -> (tempfile::TempDir, AssetCanvasDraft, Vec) { - create_refine_generation_fixture_with_source(project_id, project_name, "illustration", None) - } - - fn create_refine_generation_fixture_with_source( - project_id: &str, - project_name: &str, - source_asset_kind: &str, - source_resource_id: Option<&str>, - ) -> (tempfile::TempDir, AssetCanvasDraft, Vec) { - let directory = tempfile::tempdir().expect("create refine generation fixture"); - let mut initialized = - init_local_game_project_at(directory.path(), project_id, project_name) - .expect("initialize refine generation project"); - let source_png = test_png_with_color([38, 132, 76, 255]); - fs::write(directory.path().join("assets/source.png"), &source_png) - .expect("write refine source image"); - initialized - .manifest - .assets - .push(GameCreationAppAssetManifestEntry { - id: "source-character".to_string(), - kind: source_asset_kind.to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: source_resource_id.map(str::to_string), - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }); - write_manifest( - &directory.path().join(".agent/manifest.json"), - &initialized.manifest, - ) - .expect("persist refine source manifest"); - let draft = create_asset_canvas_draft_at( - directory.path(), - &CreateAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: Uuid::new_v4().to_string(), - intent: AssetCanvasIntent::Refine, - source_asset_id: Some("source-character".to_string()), - }, - ) - .expect("create refine generation draft") - .draft; - (directory, draft, source_png) - } - - fn generation_input( - root: &Path, - project_id: &str, - draft: &AssetCanvasDraft, - prompt: &str, - ) -> GenerateAssetCanvasImageInput { - GenerateAssetCanvasImageInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - expected_host_revision: 0, - expected_draft_revision: draft.revision, - draft_id: draft.draft_id.clone(), - intent_id: Uuid::new_v4().to_string(), - generation_id: Uuid::new_v4().to_string(), - idempotency_key: Uuid::new_v4().to_string(), - commit_id: Uuid::new_v4().to_string(), - commit_idempotency_key: Uuid::new_v4().to_string(), - prompt: prompt.to_string(), - aspect_ratio: "16:9".to_string(), - image_size: "1K".to_string(), - asset_kind: "illustration".to_string(), - asset_name: "阶段五生成图".to_string(), - reference_resource_ids: Vec::new(), - source_layer_id: draft.canvas.primary_selected_layer_id.clone(), - placeholder: Some(AssetCanvasGenerationPlaceholder { - x: 7_000.0, - y: 6_000.0, - width: 640.0, - height: 360.0, - }), - } - } - - fn prepare_asset_commit_settlement_pending_fixture( - project_id: &str, - project_name: &str, - ) -> ( - tempfile::TempDir, - AssetCanvasDraft, - GenerateAssetCanvasImageInput, - AssetCanvasGenerationLedger, - u64, - ) { - let (directory, draft) = create_generation_fixture(project_id, project_name); - let input = generation_input( - directory.path(), - project_id, - &draft, - "验证正式资源提交结算可以跨重启恢复", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare generation"); - let staged = stage_asset_canvas_image_with_token_at( - directory.path(), - &StageAssetCanvasImageInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - media_type: "image/png".to_string(), - bytes: test_png_with_color([82, 116, 210, 255]), - }, - Some(&ledger.staged_image_token), - ) - .expect("stage generated image before commit"); - ledger.current_draft_revision = Some(staged.draft_revision); - ledger.staged_draft_revision = Some(staged.draft_revision); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::MediaDownloaded, - None, - ) - .expect("persist downloaded phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish downloaded phase"); - let media_draft_revision = ledger - .current_draft_revision - .expect("downloaded projection advances draft revision"); - let execution = commit_asset_canvas_at( - directory.path(), - &CommitAssetCanvasInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - expected_revision: ledger.expected_host_revision, - expected_draft_revision: media_draft_revision, - draft_id: draft.draft_id.clone(), - commit_id: ledger.commit_id.clone(), - idempotency_key: ledger.commit_idempotency_key.clone(), - intent: ledger.intent.clone(), - source_asset_id: ledger.source_asset_id.clone(), - staged_image_token: ledger.staged_image_token.clone(), - source_layer_id: None, - media_sha256: None, - name: ledger.asset_name.clone(), - asset_kind: ledger.asset_kind.clone(), - reference_resource_ids: ledger.requested_reference_resource_ids.clone(), - generation_provenance: None, - }, - ) - .expect("commit generated image before settlement"); - let private_commit = - private_commit_result_from_execution(&execution).expect("read private commit result"); - let committed_draft_revision = private_commit.draft_revision; - ledger.current_draft_revision = Some(committed_draft_revision); - ledger.commit_result = Some(private_commit); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::AssetCommitSettlementPending, - None, - ) - .expect("persist asset commit settlement journal"); - (directory, draft, input, ledger, committed_draft_revision) - } - - fn accepted_ledger( - project_id: &str, - draft: &AssetCanvasDraft, - base_url: &str, - api_key: &str, - ) -> AssetCanvasGenerationLedger { - let body_json = serde_json::json!({ - "prompt": "重启前已经提交的私有正文", - "projectId": "remote-project", - "assetFolderId": "remote-folder", - }) - .to_string(); - let now = asset_canvas_now(); - AssetCanvasGenerationLedger { - schema_version: ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION.to_string(), - request_fingerprint: "a".repeat(64), - project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - intent: AssetCanvasIntent::Create, - source_asset_id: None, - source_resource_id: None, - source_layer_id: None, - placeholder: Some(AssetCanvasGenerationPlaceholder { - x: 6_000.0, - y: 6_000.0, - width: 512.0, - height: 512.0, - }), - intent_id: Uuid::new_v4().to_string(), - generation_id: Uuid::new_v4().to_string(), - idempotency_key: Uuid::new_v4().to_string(), - commit_id: Uuid::new_v4().to_string(), - commit_idempotency_key: Uuid::new_v4().to_string(), - expected_host_revision: 0, - expected_draft_revision: draft.revision, - current_draft_revision: Some(draft.revision), - prompt: "重启前已经提交的私有正文".to_string(), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "illustration".to_string(), - asset_name: "恢复生成图".to_string(), - requested_reference_resource_ids: Vec::new(), - reference_states: Vec::new(), - resolved_reference_ids: Vec::new(), - reference_binding_repair_attempted: false, - access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), - api_identity_scheme: None, - api_identity_fingerprint: Some( - platform_art_generation_external_configuration_fingerprint(base_url, api_key), - ), - platform_owner_user_id: None, - platform_owner_api_base_url: None, - service_identity_confirmation: None, - canvas_context: Some(PrivateCanvasContext { - project_id: "remote-project".to_string(), - asset_folder_id: "remote-folder".to_string(), - canvas_name: "恢复生成测试".to_string(), - }), - endpoint: Some("/api/external/v1/editor/images/generations".to_string()), - request_body_sha256: Some(asset_canvas_sha256(body_json.as_bytes())), - request_body_json: Some(body_json), - phase: GenerationLedgerPhase::Accepted, - operation_id: Some("accepted-operation-phase-five".to_string()), - poll_after_ms: Some(0), - remote_result: None, - staged_image_token: Uuid::new_v4().to_string(), - staged_draft_revision: None, - candidate_media_ref: None, - candidate_layer_id: None, - candidate_layer_acknowledged_revision: None, - commit_result: None, - error_code: None, - archived_at: None, - created_at: now, - updated_at: now, - } - } - - #[test] - fn generation_input_uses_the_fixed_camel_case_placement_contract() { - let source_layer_id = Uuid::new_v4().to_string(); - let input: GenerateAssetCanvasImageInput = serde_json::from_value(serde_json::json!({ - "projectPath": "C:/project", - "expectedProjectId": "project", - "expectedHostRevision": 0, - "expectedDraftRevision": 1, - "draftId": Uuid::new_v4().to_string(), - "intentId": Uuid::new_v4().to_string(), - "generationId": Uuid::new_v4().to_string(), - "idempotencyKey": Uuid::new_v4().to_string(), - "commitId": Uuid::new_v4().to_string(), - "commitIdempotencyKey": Uuid::new_v4().to_string(), - "prompt": "fixed contract", - "aspectRatio": "1:1", - "imageSize": "1K", - "assetKind": "illustration", - "assetName": "合同测试", - "referenceResourceIds": [], - "sourceLayerId": source_layer_id.clone(), - "placeholder": { - "x": 100.5, - "y": 200.25, - "width": 512.0, - "height": 512.0 - } - })) - .expect("deserialize fixed generation placement contract"); - assert_eq!( - input.source_layer_id.as_deref(), - Some(source_layer_id.as_str()) - ); - assert_eq!( - input.placeholder, - Some(AssetCanvasGenerationPlaceholder { - x: 100.5, - y: 200.25, - width: 512.0, - height: 512.0, - }) - ); - } - - #[test] - fn generation_placement_is_persisted_and_candidate_reuses_the_frozen_placeholder() { - let project_id = "generation-placement-project"; - let (directory, draft, candidate_png) = - create_refine_generation_fixture(project_id, "生成占位持久化测试"); - let source_layer_id = draft - .canvas - .primary_selected_layer_id - .clone() - .expect("refine draft source layer"); - let placeholder = AssetCanvasGenerationPlaceholder { - x: 7_125.5, - y: 5_875.25, - width: 720.0, - height: 405.0, - }; - let mut input = generation_input( - directory.path(), - project_id, - &draft, - "保留任务占位并生成候选", - ); - input.source_layer_id = Some(source_layer_id.clone()); - input.placeholder = Some(placeholder.clone()); - - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare placement ledger"); - assert_eq!( - ledger.source_layer_id.as_deref(), - Some(source_layer_id.as_str()) - ); - assert_eq!(ledger.placeholder.as_ref(), Some(&placeholder)); - let serialized = fs::read_to_string( - directory - .path() - .join(generation_ledger_relative_path(&input.generation_id)), - ) - .expect("read serialized placement ledger"); - let serialized: serde_json::Value = - serde_json::from_str(&serialized).expect("parse placement ledger"); - assert_eq!(serialized["sourceLayerId"], source_layer_id); - assert_eq!(serialized["placeholder"]["x"], placeholder.x); - assert_eq!(serialized["placeholder"]["height"], placeholder.height); - - let staged = stage_asset_canvas_image_with_token_at( - directory.path(), - &StageAssetCanvasImageInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - media_type: "image/png".to_string(), - bytes: candidate_png, - }, - Some(&ledger.staged_image_token), - ) - .expect("stage placement candidate"); - assert_eq!(staged.status, "staged"); - ledger.current_draft_revision = Some(staged.draft_revision); - ledger.staged_draft_revision = Some(staged.draft_revision); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::CandidateReady, - None, - ) - .expect("persist candidate-ready placement ledger"); - - let (record, updated, _) = persist_generation_candidate_at(directory.path(), &mut ledger) - .expect("persist candidate at frozen placeholder"); - assert_eq!( - record.source_layer_id.as_deref(), - Some(source_layer_id.as_str()) - ); - assert_eq!(record.placeholder.as_ref(), Some(&placeholder)); - let candidate_layer_id = ledger - .candidate_layer_id - .as_deref() - .expect("candidate layer id"); - let candidate_layer = updated - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == candidate_layer_id) - .expect("candidate layer"); - assert_eq!(candidate_layer.x, placeholder.x); - assert_eq!(candidate_layer.y, placeholder.y); - assert_eq!(candidate_layer.width, placeholder.width); - assert_eq!(candidate_layer.height, placeholder.height); - - // Simulate the observed stale-autosave failure: the authoritative draft - // loses an unacknowledged candidate, then recovery restores it from the - // private ledger before the canvas is hydrated again. - let mut lost_draft = updated.clone(); - lost_draft - .canvas - .layers - .retain(|layer| layer.layer_id != candidate_layer_id); - lost_draft.canvas.selected_layer_ids = vec![source_layer_id.clone()]; - lost_draft.canvas.primary_selected_layer_id = Some(source_layer_id.clone()); - write_asset_canvas_draft_locked(directory.path(), &lost_draft) - .expect("write draft without candidate"); - let restored = restore_unacknowledged_candidate_layers_at( - directory.path(), - project_id, - &draft.draft_id, - ) - .expect("restore unacknowledged candidate") - .expect("candidate restored"); - assert!(restored - .canvas - .layers - .iter() - .any(|layer| layer.layer_id == candidate_layer_id)); - - let mut stale_canvas = restored.canvas.clone(); - stale_canvas - .layers - .retain(|layer| layer.layer_id != candidate_layer_id); - stale_canvas.selected_layer_ids = vec![source_layer_id.clone()]; - stale_canvas.primary_selected_layer_id = Some(source_layer_id.clone()); - let protected = update_asset_canvas_draft_at( - directory.path(), - &UpdateAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: restored.revision, - status: AssetCanvasDraftStatus::Editing, - canvas: stale_canvas, - generations: restored.generations.clone(), - }, - ) - .expect("protect stale autosave") - .draft; - assert!(protected - .canvas - .layers - .iter() - .any(|layer| layer.layer_id == candidate_layer_id)); - - let still_unacknowledged = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read protected candidate ledger") - .expect("protected candidate ledger retained"); - assert_eq!( - still_unacknowledged.candidate_layer_acknowledged_revision, - None - ); - - let ignored_acknowledgement = acknowledge_candidate_layers_at( - directory.path(), - &AcknowledgeAssetCanvasCandidateLayersInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - layer_ids: vec![source_layer_id.clone(), "unknown-layer".to_string()], - }, - ) - .expect("ignore unknown and non-candidate layer acknowledgement"); - assert_eq!(ignored_acknowledgement.draft.revision, protected.revision); - let ignored_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read ignored acknowledgement ledger") - .expect("ignored acknowledgement ledger retained"); - assert_eq!(ignored_ledger.candidate_layer_acknowledged_revision, None); - - let acknowledged = acknowledge_candidate_layers_at( - directory.path(), - &AcknowledgeAssetCanvasCandidateLayersInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - layer_ids: vec![candidate_layer_id.to_string()], - }, - ) - .expect("acknowledge candidate"); - assert_eq!(acknowledged.draft.revision, protected.revision); - let acknowledged_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read acknowledged ledger") - .expect("acknowledged ledger retained"); - assert_eq!( - acknowledged_ledger.candidate_layer_acknowledged_revision, - Some(protected.revision) - ); - - let repeated = acknowledge_candidate_layers_at( - directory.path(), - &AcknowledgeAssetCanvasCandidateLayersInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - layer_ids: vec![candidate_layer_id.to_string()], - }, - ) - .expect("repeat candidate acknowledgement"); - assert_eq!(repeated.draft.revision, protected.revision); - let repeated_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read repeated acknowledgement ledger") - .expect("repeated acknowledgement ledger retained"); - assert_eq!(repeated_ledger, acknowledged_ledger); - - let mut intentional_delete_canvas = acknowledged.draft.canvas.clone(); - intentional_delete_canvas - .layers - .retain(|layer| layer.layer_id != candidate_layer_id); - intentional_delete_canvas.selected_layer_ids = vec![source_layer_id.clone()]; - intentional_delete_canvas.primary_selected_layer_id = Some(source_layer_id.clone()); - let after_delete = update_asset_canvas_draft_at( - directory.path(), - &UpdateAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: acknowledged.draft.revision, - status: AssetCanvasDraftStatus::Editing, - canvas: intentional_delete_canvas, - generations: acknowledged.draft.generations.clone(), - }, - ) - .expect("allow intentional candidate deletion after acknowledgement") - .draft; - assert!(!after_delete - .canvas - .layers - .iter() - .any(|layer| layer.layer_id == candidate_layer_id)); - - let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read ledger after restart") - .expect("placement ledger retained"); - assert_eq!(recovered_ledger.source_layer_id, Some(source_layer_id)); - assert_eq!(recovered_ledger.placeholder, Some(placeholder.clone())); - let recovered_draft = after_delete; - let recovered_record = recovered_draft - .generations - .iter() - .find(|record| record.generation_id == input.generation_id) - .expect("public placement record retained"); - assert_eq!(recovered_record.placeholder.as_ref(), Some(&placeholder)); - - let mut unsafe_input = generation_input( - directory.path(), - project_id, - &recovered_draft, - "拒绝越界占位", - ); - unsafe_input.placeholder = Some(AssetCanvasGenerationPlaceholder { - x: 11_900.0, - y: 100.0, - width: 200.0, - height: 200.0, - }); - assert!( - validate_and_prepare_ledger(directory.path(), &unsafe_input, "", None) - .expect_err("reject out-of-bounds placeholder") - .contains("安全画布边界") - ); - } - - #[tokio::test] - async fn context_authentication_required_replays_the_same_generation_after_refresh() { - let project_id = "phase-five-auth-refresh-project"; - let project_name = "阶段五登录刷新重放测试"; - let (directory, draft) = create_generation_fixture(project_id, project_name); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth refresh server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("auth refresh address") - ); - let signed_url = format!("{base_url}/auth-refresh.png"); - let server_signed_url = signed_url.clone(); - let png = test_png(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set auth refresh fixture nonblocking"); - for request_index in 0..9 { - let mut stream = accept_generation_fixture_connection( - &listener, - "auth refresh fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture auth refresh request"); - if request_index == 0 { - assert!(request.starts_with("GET /api/external/v1/editor/projects ")); - assert!(request - .to_ascii_lowercase() - .contains("authorization: bearer expired-key")); - write_json( - &mut stream, - "401 Unauthorized", - serde_json::json!({"error": {"code": "authentication-required"}}), - ); - } else if request.starts_with("GET /api/external/v1/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "auth-refresh-remote-project", - "title": project_name, - }]}}), - ); - } else if request.starts_with("POST /api/external/v1/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"project": { - "projectId": "auth-refresh-remote-project", - "title": project_name, - }}}), - ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "auth-refresh-remote-folder", - "label": project_name, - }]}}}), - ); - } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"folder": { - "folderId": "auth-refresh-remote-folder", - "label": project_name, - }}}), - ); - } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { - write_json( - &mut stream, - "202 Accepted", - serde_json::json!({"data": { - "operationId": "auth-refresh-operation", - "status": "queued", - "pollAfterMs": 0, - }}), - ); - } else if request - .starts_with("GET /api/external/v1/generations/auth-refresh-operation ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "auth-refresh-operation", - "status": "completed", - "pollAfterMs": 0, - "result": {"resource": { - "resourceId": "auth-refresh-resource", - "objectKey": "generated/auth-refresh.png", - "assetObjectId": "auth-refresh-object", - }} - }}), - ); - } else if request.starts_with("GET /api/external/v1/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": server_signed_url}}), - ); - } else if request.starts_with("GET /auth-refresh.png ") { - write_png(&mut stream, &png); - } else { - panic!("unexpected auth refresh request: {request}"); - } - } - }); - - let input = generation_input( - directory.path(), - project_id, - &draft, - "刷新登录态后继续原图片生成", - ); - let mut first_progress = Vec::new(); - let first_error = - match with_test_external_editor_credentials(&base_url, "expired-key", async { - generate_asset_canvas_image_at(directory.path(), &input, |event| { - first_progress.push(event) - }) - .await - }) - .await - { - Ok(_) => panic!("first request must expose authentication failure"), - Err(error) => error, - }; - assert!( - first_error.contains("登录已失效") || first_error.contains("API Key 无效"), - "authentication failure must remain recognizable without exposing credentials" - ); - assert_eq!( - first_progress.last().map(|event| event.phase.as_str()), - Some("reconciliation-required") - ); - let recoverable = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read authentication ledger") - .expect("authentication ledger exists"); - assert_eq!( - recoverable.phase, - GenerationLedgerPhase::ReconciliationRequired - ); - assert_eq!( - recoverable.error_code.as_deref(), - Some("authentication-required") - ); - assert!(recoverable.operation_id.is_none()); - assert!(recoverable.request_body_json.is_none()); - - let recovered = with_test_external_editor_credentials(&base_url, "refreshed-key", async { - generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await - }) - .await - .expect("refreshed credentials replay the original generation"); - server.join().expect("join auth refresh server"); - assert_eq!( - recovered.result.generation.phase, - AssetCanvasGenerationStatus::CandidateReady - ); - assert_eq!( - recovered.result.generation.generation_id, - input.generation_id - ); - - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 9); - assert!(requests[1] - .to_ascii_lowercase() - .contains("authorization: bearer refreshed-key")); - let submits = requests - .iter() - .filter(|request| { - request.starts_with("POST /api/external/v1/editor/images/generations ") - }) - .collect::>(); - assert_eq!(submits.len(), 1); - assert!(submits[0] - .to_ascii_lowercase() - .contains(&format!("idempotency-key: {}", input.idempotency_key))); - } - - #[tokio::test] - async fn confirmed_generation_posts_once_replays_without_network_and_keeps_public_state_clean() - { - let project_id = "phase-five-generation-project"; - let project_name = "阶段五生成测试项目"; - let (directory, draft) = create_generation_fixture(project_id, project_name); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind generation server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("generation address") - ); - let api_key = "phase-five-secret-api-key"; - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - api_key, - None, - "remote-project", - "remote-folder", - ); - let signed_url = format!("{base_url}/generated.png"); - let server_signed_url = signed_url.clone(); - let png = test_png(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set generation fixture nonblocking"); - for request_index in 0..6 { - let mut stream = accept_generation_fixture_connection( - &listener, - "generation fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture generation request"); - if request.starts_with("GET /api/external/v1/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "remote-project", - "title": project_name, - }]}}), - ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "remote-folder", - "label": project_name, - }]}}}), - ); - } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { - write_json( - &mut stream, - "202 Accepted", - serde_json::json!({"data": { - "operationId": "phase-five-operation", - "status": "queued", - "pollAfterMs": 0, - }}), - ); - } else if request - .starts_with("GET /api/external/v1/generations/phase-five-operation ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "phase-five-operation", - "status": "completed", - "pollAfterMs": 0, - "result": { - "taskId": "external-task-must-stay-private", - "resource": { - "resourceId": "remote-resource", - "objectKey": "generated/result.png", - "assetObjectId": "remote-object", - } - } - }}), - ); - } else if request.starts_with("GET /api/external/v1/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": server_signed_url}}), - ); - } else if request.starts_with("GET /generated.png ") { - write_png(&mut stream, &png); - } else { - panic!("unexpected generation request: {request}"); - } - } - }); - let private_prompt = "只允许进入私有账本的完整提示词正文"; - let input = generation_input(directory.path(), project_id, &draft, private_prompt); - let mut progress = Vec::new(); - let first = with_test_external_editor_credentials(&base_url, api_key, async { - generate_asset_canvas_image_at(directory.path(), &input, |event| progress.push(event)) - .await - }) - .await - .expect("generate candidate image"); - server.join().expect("join generation server"); - - assert_eq!( - first.result.generation.phase, - AssetCanvasGenerationStatus::CandidateReady - ); - assert!(first.result.commit.is_none()); - assert_eq!(first.result.images.len(), 1); - assert!(progress - .iter() - .any(|event| event.phase == "generation-accepted")); - assert!(progress - .iter() - .any(|event| event.phase == "generation-running")); - assert!(progress - .iter() - .any(|event| event.phase == "remote-completed")); - assert!(progress - .iter() - .any(|event| event.phase == "media-downloaded")); - assert!(progress - .iter() - .any(|event| event.phase == "candidate-ready")); - assert!(progress - .windows(2) - .all(|events| events[0].draft_revision < events[1].draft_revision)); - assert_eq!( - progress.last().map(|event| event.draft_revision), - Some(first.result.draft.revision) - ); - let private = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read committed private ledger") - .expect("committed private ledger exists"); - assert_eq!( - private.current_draft_revision, - Some(first.result.draft.revision) - ); - assert_eq!( - private.staged_draft_revision, - Some(first.result.draft.revision - 1) - ); - let (staged, _) = read_staged_image_locked(directory.path(), &private.staged_image_token) - .expect("read committed staging image"); - assert_eq!(staged.draft_revision, first.result.draft.revision - 1); - - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 6); - let submits = requests - .iter() - .filter(|request| { - request.starts_with("POST /api/external/v1/editor/images/generations ") - }) - .collect::>(); - assert_eq!(submits.len(), 1); - assert!(submits[0] - .to_ascii_lowercase() - .contains(&format!("idempotency-key: {}", input.idempotency_key))); - - let replay = with_test_external_editor_credentials(&base_url, api_key, async { - generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await - }) - .await - .expect("replay candidate generation"); - assert_eq!(replay.result.commit, first.result.commit); - assert!(replay.event.is_none()); - - let mut conflicting = - generation_input(directory.path(), project_id, &draft, private_prompt); - conflicting.intent_id = input.intent_id.clone(); - conflicting.idempotency_key = input.idempotency_key.clone(); - let conflict = with_test_external_editor_credentials(&base_url, api_key, async { - generate_asset_canvas_image_at(directory.path(), &conflicting, |_| {}).await - }) - .await - .err() - .expect("reject identity reuse under another generation"); - assert!(conflict.contains("不同 generationId")); - - let public_draft = read_asset_canvas_draft_at( - directory.path(), - &ReadAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - ) - .expect("read public generation draft") - .draft - .expect("public draft exists"); - let public_draft_json = - serde_json::to_string(&public_draft).expect("serialize public draft"); - let manifest = validate_asset_canvas_project_identity(directory.path(), project_id) - .expect("read manifest"); - let manifest_json = serde_json::to_string(&manifest).expect("serialize manifest"); - let progress_json = serde_json::to_string(&progress).expect("serialize progress"); - for public in [ - public_draft_json.as_str(), - manifest_json.as_str(), - progress_json.as_str(), - ] { - assert!(!public.contains(private_prompt)); - assert!(!public.contains(api_key)); - assert!(!public.contains(&base_url)); - assert!(!public.contains(&input.idempotency_key)); - assert!(!public.contains(&input.commit_idempotency_key)); - assert!(!public.contains("phase-five-operation")); - assert!(!public.contains("external-task-must-stay-private")); - } - let private_ledger = fs::read_to_string( - directory - .path() - .join(generation_ledger_relative_path(&input.generation_id)), - ) - .expect("read private generation ledger"); - assert!(private_ledger.contains(private_prompt)); - assert!(private_ledger.contains(&input.idempotency_key)); - assert!(private_ledger.contains("phase-five-operation")); - assert!(!private_ledger.contains(api_key)); - assert!(!private_ledger.contains(&base_url)); - assert!(!private_ledger.contains(&signed_url)); - assert!(!private_ledger.contains("external-task-must-stay-private")); - let absolute_project_path = directory.path().to_string_lossy(); - assert!(!private_ledger.contains(absolute_project_path.as_ref())); - assert!(!public_draft_json.contains(absolute_project_path.as_ref())); - assert!(!manifest_json.contains(absolute_project_path.as_ref())); - assert!(!progress_json.contains(absolute_project_path.as_ref())); - } - - #[test] - fn legacy_generation_ledgers_default_archived_at_to_none() { - let project_id = "asset-canvas-legacy-archive-ledger"; - let (directory, draft) = create_generation_fixture(project_id, "旧生成账本归档字段测试"); - let ledger = accepted_ledger(project_id, &draft, "http://127.0.0.1:9", "api-key"); - let mut value = serde_json::to_value(ledger).expect("serialize legacy ledger fixture"); - value - .as_object_mut() - .expect("ledger object") - .remove("archivedAt"); - let decoded: AssetCanvasGenerationLedger = - serde_json::from_value(value).expect("deserialize old ledger without archivedAt"); - assert_eq!(decoded.archived_at, None); - drop(directory); - } - - #[tokio::test] - async fn generation_failure_settlement_is_authoritative_cas_and_handles_missing_ledger() { - let project_id = "asset-canvas-generation-failure-settlement"; - let (directory, draft) = create_generation_fixture(project_id, "生成失败结算测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "验证失败结算不会留下运行中任务", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare generation"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Accepted, - None, - ) - .expect("persist accepted phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish accepted phase"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist running phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish running phase"); - let running_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read running draft") - .expect("running draft exists"); - - let stale = FinalizeAssetCanvasGenerationFailureInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - expected_draft_revision: running_draft.revision - 1, - draft_id: draft.draft_id.clone(), - generation_id: input.generation_id.clone(), - intent_id: input.intent_id.clone(), - error_code: "generation-rejected".to_string(), - reconciliation_required: false, - }; - assert_eq!( - finalize_asset_canvas_generation_failure_at(directory.path(), &stale) - .await - .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, - ..stale.clone() - }; - let settled = finalize_asset_canvas_generation_failure_at(directory.path(), &settle) - .await - .expect("settle failed generation"); - assert_eq!(settled.disposition, "failed"); - assert_eq!(settled.draft.revision, running_draft.revision + 1); - assert!(settled.draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::Failed - && record.error_code.as_deref() == Some("generation-rejected") - })); - - let replay = finalize_asset_canvas_generation_failure_at( - directory.path(), - &FinalizeAssetCanvasGenerationFailureInput { - expected_draft_revision: settled.draft.revision, - ..settle.clone() - }, - ) - .await - .expect("failure settlement is idempotent"); - assert_eq!(replay.draft.revision, settled.draft.revision); - assert_eq!(replay.disposition, "failed"); - - let (directory, draft) = create_generation_fixture( - "asset-canvas-generation-failure-no-ledger", - "无账本失败结算测试", - ); - let input = generation_input( - directory.path(), - "asset-canvas-generation-failure-no-ledger", - &draft, - "无账本失败占位", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare placeholder"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist placeholder running phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish placeholder running phase"); - let placeholder_draft = read_asset_canvas_draft_locked( - directory.path(), - "asset-canvas-generation-failure-no-ledger", - &draft.draft_id, - ) - .expect("read placeholder draft") - .expect("placeholder draft exists"); - fs::remove_file( - directory - .path() - .join(generation_ledger_relative_path(&input.generation_id)), - ) - .expect("remove private ledger before settlement"); - let settled = finalize_asset_canvas_generation_failure_at( - directory.path(), - &FinalizeAssetCanvasGenerationFailureInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: "asset-canvas-generation-failure-no-ledger".to_string(), - expected_draft_revision: placeholder_draft.revision, - draft_id: draft.draft_id, - generation_id: input.generation_id, - intent_id: input.intent_id, - error_code: "canvas-generation-failed".to_string(), - reconciliation_required: false, - }, - ) - .await - .expect("settle pre-ledger failure"); - assert_eq!(settled.disposition, "not-started"); - assert!(settled.draft.generations.is_empty()); - assert_eq!(settled.draft.status, AssetCanvasDraftStatus::Editing); - } - - #[tokio::test] - async fn internal_generation_error_recovers_after_pending_ledger_cut() { - let project_id = "asset-canvas-internal-error-pending"; - let (directory, draft) = create_generation_fixture(project_id, "内部失败 pending 恢复测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "恢复生成流程内部失败的 pending 结算", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare generation"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist running phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish running phase"); - let running_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read running draft") - .expect("running draft exists"); - - let error = mark_generation_error_with_pending_hook( - directory.path(), - &mut ledger, - false, - "generation-rejected", - &mut |_| {}, - || Err("fault-injected:failure-settlement-pending".to_string()), - ) - .expect_err("fault after pending ledger must interrupt settlement"); - assert_eq!(error, "fault-injected:failure-settlement-pending"); - let interrupted_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read interrupted internal failure ledger") - .expect("interrupted internal failure ledger exists"); - assert_eq!( - interrupted_ledger.phase, - GenerationLedgerPhase::FailureSettlementPending - ); - let interrupted_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read interrupted internal failure draft") - .expect("interrupted internal failure draft exists"); - assert_eq!(interrupted_draft.revision, running_draft.revision); - assert!(interrupted_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::GenerationRunning - })); - - recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("recover internal generation error settlement"); - let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered internal failure ledger") - .expect("recovered internal failure ledger exists"); - assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered internal failure draft") - .expect("recovered internal failure draft exists"); - assert_eq!(recovered_draft.revision, running_draft.revision + 1); - assert!(recovered_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::Failed - && record.error_code.as_deref() == Some("generation-rejected") - })); - } - - #[tokio::test] - async fn pending_generation_failure_settlement_recovers_across_all_durable_write_cuts() { - let project_id = "asset-canvas-failure-pending-before-public"; - let (directory, draft) = create_generation_fixture(project_id, "失败结算 pending 恢复测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "恢复尚未写入公开草稿的失败结算", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare generation"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist running phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish running phase"); - let running_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read running draft") - .expect("running draft exists"); - ledger.phase = GenerationLedgerPhase::FailureSettlementPending; - ledger.error_code = Some("generation-rejected".to_string()); - ledger.current_draft_revision = Some(running_draft.revision); - write_generation_ledger(directory.path(), &mut ledger) - .expect("persist failure settlement journal"); - - let mut progress = Vec::new(); - let recovered = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |event| progress.push(event), - ) - .await - .expect("recover pending failure settlement before public projection"); - assert!(recovered.result.resumed_generation_ids.is_empty()); - let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered failure ledger") - .expect("recovered failure ledger exists"); - assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered failure draft") - .expect("recovered failure draft exists"); - assert_eq!(recovered_draft.revision, running_draft.revision + 1); - assert!(recovered_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::Failed - && record.error_code.as_deref() == Some("generation-rejected") - })); - assert_eq!(progress.len(), 1); - assert_eq!(progress[0].phase, "failed"); - - let project_id = "asset-canvas-reconciliation-pending-after-public"; - let (directory, draft) = create_generation_fixture(project_id, "对账结算 pending 恢复测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "恢复已写公开草稿但未写终态账本的对账结算", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare generation"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist running phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish running phase"); - let running_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read running draft") - .expect("running draft exists"); - let staged = stage_asset_canvas_image_with_token_at( - directory.path(), - &StageAssetCanvasImageInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: running_draft.revision, - media_type: "image/png".to_string(), - bytes: test_png(), - }, - Some(&ledger.staged_image_token), - ) - .expect("stage reconciliation candidate"); - assert_eq!(staged.status, "staged"); - ledger.phase = GenerationLedgerPhase::ReconciliationSettlementPending; - ledger.error_code = Some("poll-result-unknown".to_string()); - ledger.current_draft_revision = Some(running_draft.revision); - ledger.staged_draft_revision = Some(running_draft.revision); - write_generation_ledger(directory.path(), &mut ledger) - .expect("persist reconciliation settlement journal"); - let mut projection = ledger.clone(); - projection.phase = GenerationLedgerPhase::ReconciliationRequired; - let (_, projected_revision) = - upsert_public_generation_record(directory.path(), &projection) - .expect("simulate public projection before terminal ledger write"); - let (staged_before_recovery, _) = - read_staged_image_locked(directory.path(), &ledger.staged_image_token) - .expect("read staging before reconciliation recovery"); - assert_eq!( - staged_before_recovery.draft_revision, - running_draft.revision - ); - - let mut progress = Vec::new(); - recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |event| progress.push(event), - ) - .await - .expect("recover pending reconciliation after public projection"); - let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered reconciliation ledger") - .expect("recovered reconciliation ledger exists"); - assert_eq!( - recovered_ledger.phase, - GenerationLedgerPhase::ReconciliationRequired - ); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered reconciliation draft") - .expect("recovered reconciliation draft exists"); - assert_eq!(recovered_draft.revision, projected_revision); - let (recovered_staging, _) = - read_staged_image_locked(directory.path(), &ledger.staged_image_token) - .expect("read rebound staging after reconciliation recovery"); - assert_eq!(recovered_staging.draft_revision, projected_revision); - assert!(recovered_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::ReconciliationRequired - && record.error_code.as_deref() == Some("poll-result-unknown") - })); - assert_eq!(progress.len(), 1); - assert_eq!(progress[0].phase, "reconciliation-required"); - - let project_id = "asset-canvas-failure-pending-after-staging"; - let (directory, draft) = - create_generation_fixture(project_id, "失败结算 staging 后恢复测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "恢复已推进 staging 但未写终态账本的失败结算", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare generation"); - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist running phase"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("publish running phase"); - let running_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read running draft") - .expect("running draft exists"); - let staged = stage_asset_canvas_image_with_token_at( - directory.path(), - &StageAssetCanvasImageInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: running_draft.revision, - media_type: "image/png".to_string(), - bytes: test_png_with_color([22, 44, 66, 255]), - }, - Some(&ledger.staged_image_token), - ) - .expect("stage failure candidate"); - assert_eq!(staged.status, "staged"); - ledger.phase = GenerationLedgerPhase::FailureSettlementPending; - ledger.error_code = Some("generation-rejected".to_string()); - ledger.current_draft_revision = Some(running_draft.revision); - ledger.staged_draft_revision = Some(running_draft.revision); - write_generation_ledger(directory.path(), &mut ledger) - .expect("persist failure settlement journal"); - let mut projection = ledger.clone(); - projection.phase = GenerationLedgerPhase::Failed; - let (_, projected_revision) = - upsert_public_generation_record(directory.path(), &projection) - .expect("simulate failure public projection before staging revision"); - rebind_asset_canvas_staged_image_revision_at( - directory.path(), - &ledger.staged_image_token, - project_id, - &draft.draft_id, - running_draft.revision, - projected_revision, - ) - .expect("simulate staging revision before terminal ledger write"); - - let mut progress = Vec::new(); - recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |event| progress.push(event), - ) - .await - .expect("recover pending failure after staging revision"); - let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered failure ledger") - .expect("recovered failure ledger exists"); - assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); - assert_eq!( - recovered_ledger.staged_draft_revision, - Some(projected_revision) - ); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered failure draft") - .expect("recovered failure draft exists"); - assert_eq!(recovered_draft.revision, projected_revision); - let (recovered_staging, _) = - read_staged_image_locked(directory.path(), &ledger.staged_image_token) - .expect("read staging after terminal ledger recovery"); - assert_eq!(recovered_staging.draft_revision, projected_revision); - assert_eq!(progress.len(), 1); - assert_eq!(progress[0].phase, "failed"); - } - - #[tokio::test] - async fn pending_asset_commit_settlement_recovers_across_all_durable_write_cuts() { - for cut in ["before-public", "after-public", "after-staging"] { - let project_id = format!("asset-commit-settlement-{cut}"); - let (directory, draft, input, ledger, committed_draft_revision) = - prepare_asset_commit_settlement_pending_fixture( - &project_id, - "正式资源提交 pending 恢复测试", - ); - let interrupted_draft = - read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) - .expect("read interrupted commit draft") - .expect("interrupted commit draft exists"); - assert_eq!(interrupted_draft.revision, committed_draft_revision); - assert!(interrupted_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::MediaDownloaded - && record.output_asset_id.is_none() - })); - - let mut projected_revision = committed_draft_revision + 1; - if cut != "before-public" { - let mut projection = ledger.clone(); - projection.phase = GenerationLedgerPhase::AssetDurableCommitted; - let (_, revision) = upsert_public_generation_record(directory.path(), &projection) - .expect("simulate public commit projection before terminal ledger write"); - projected_revision = revision; - } - if cut == "after-staging" { - let (metadata, _) = - read_staged_image_locked(directory.path(), &ledger.staged_image_token) - .expect("read staging before simulated rebind"); - rebind_asset_canvas_staged_image_revision_at( - directory.path(), - &ledger.staged_image_token, - &project_id, - &draft.draft_id, - metadata.draft_revision, - projected_revision, - ) - .expect("simulate staging revision before terminal ledger write"); - } - - let mut progress = Vec::new(); - let recovered = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.clone(), - draft_id: draft.draft_id.clone(), - }, - |event| progress.push(event), - ) - .await - .expect("recover pending asset commit settlement"); - assert!(recovered.result.resumed_generation_ids.is_empty()); - assert!(recovered.result.service_identity_confirmations.is_empty()); - let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered commit ledger") - .expect("recovered commit ledger exists"); - assert_eq!( - recovered_ledger.phase, - GenerationLedgerPhase::AssetDurableCommitted - ); - assert_eq!( - recovered_ledger.current_draft_revision, - Some(projected_revision) - ); - assert_eq!( - recovered_ledger.staged_draft_revision, - Some(projected_revision) - ); - assert_eq!( - recovered_ledger - .commit_result - .as_ref() - .expect("recovered commit result") - .draft_revision, - projected_revision - ); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) - .expect("read recovered commit draft") - .expect("recovered commit draft exists"); - assert_eq!(recovered_draft.revision, projected_revision); - assert_eq!( - recovered_draft - .generations - .iter() - .filter(|record| record.generation_id == input.generation_id) - .count(), - 1 - ); - let recovered_generation = recovered_draft - .generations - .iter() - .find(|record| record.generation_id == input.generation_id) - .expect("recovered public generation"); - assert_eq!( - recovered_generation.phase, - AssetCanvasGenerationStatus::AssetDurableCommitted - ); - assert_eq!( - recovered_generation.output_asset_id.as_deref(), - recovered_ledger - .commit_result - .as_ref() - .map(|result| result.asset_id.as_str()) - ); - let (recovered_staging, _) = - read_staged_image_locked(directory.path(), &ledger.staged_image_token) - .expect("read recovered commit staging"); - assert_eq!(recovered_staging.draft_revision, projected_revision); - assert_eq!(progress.len(), 1); - assert_eq!(progress[0].phase, "asset-durable-committed"); - } - } - - #[tokio::test] - async fn pending_asset_commit_settlement_rejects_mismatched_commit_evidence() { - for mismatch in ["private-receipt", "commit-ledger", "transaction-journal"] { - let project_id = format!("asset-commit-evidence-{mismatch}"); - let (directory, draft, input, mut generation_ledger, _) = - prepare_asset_commit_settlement_pending_fixture( - &project_id, - "正式资源提交证据错配测试", - ); - match mismatch { - "private-receipt" => { - generation_ledger - .commit_result - .as_mut() - .expect("pending commit receipt") - .commit_id = Uuid::new_v4().to_string(); - write_generation_ledger(directory.path(), &mut generation_ledger) - .expect("persist mismatched private receipt"); - } - "commit-ledger" => { - let mut commit_ledger = - read_asset_canvas_ledger(directory.path(), &generation_ledger.commit_id) - .expect("read stage-three commit ledger") - .expect("stage-three commit ledger exists"); - commit_ledger.idempotency_key = Uuid::new_v4().to_string(); - write_asset_canvas_ledger(directory.path(), &commit_ledger) - .expect("persist mismatched stage-three commit ledger"); - } - "transaction-journal" => { - let mut journal = - read_asset_canvas_journal(directory.path(), &generation_ledger.commit_id) - .expect("read stage-three transaction journal") - .expect("stage-three transaction journal exists"); - journal.asset_id = "mismatched-asset".to_string(); - write_asset_canvas_journal(directory.path(), &journal) - .expect("persist mismatched stage-three transaction journal"); - } - _ => unreachable!(), - } - - let error = match recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.clone(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - { - Ok(_) => panic!("mismatched commit evidence must fail closed"), - Err(error) => error, - }; - assert!(error.contains("commit 回执与阶段三事务不一致")); - let retained_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read retained generation ledger") - .expect("retained generation ledger exists"); - assert_eq!( - retained_ledger.phase, - GenerationLedgerPhase::AssetCommitSettlementPending - ); - let retained_draft = - read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) - .expect("read retained draft") - .expect("retained draft exists"); - assert!(retained_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::MediaDownloaded - && record.output_asset_id.is_none() - })); - } - } - - #[tokio::test] - async fn failed_generation_archive_is_revision_safe_durable_and_idempotent() { - let project_id = "asset-canvas-failed-archive-project"; - let (directory, draft) = create_generation_fixture(project_id, "失败生成归档测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "创建一个会明确失败的测试图片", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare failed ledger"); - mark_generation_error( - directory.path(), - &mut ledger, - false, - "generation-rejected", - &mut |_| {}, - ) - .expect("persist failed generation"); - let failed_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read failed draft") - .expect("failed draft exists"); - assert!(failed_draft.generations.iter().any(|record| { - record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::Failed - })); - - let stale_archive_input = ArchiveAssetCanvasGenerationInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - expected_draft_revision: failed_draft.revision - 1, - draft_id: draft.draft_id.clone(), - generation_id: input.generation_id.clone(), - }; - let stale_error = - archive_failed_asset_canvas_generation_at(directory.path(), &stale_archive_input) - .await - .expect_err("stale archive revision must fail"); - assert_eq!(stale_error, "draft-revision-conflict"); - assert_eq!( - read_generation_ledger(directory.path(), &input.generation_id) - .expect("read ledger after stale archive") - .expect("failed ledger remains") - .phase, - GenerationLedgerPhase::Failed - ); - - let archive_input = ArchiveAssetCanvasGenerationInput { - expected_draft_revision: failed_draft.revision, - ..stale_archive_input - }; - let archived = archive_failed_asset_canvas_generation_at(directory.path(), &archive_input) - .await - .expect("archive failed generation"); - assert_eq!(archived.phase, "archived"); - assert_eq!(archived.draft.revision, failed_draft.revision + 1); - assert!(!archived - .draft - .generations - .iter() - .any(|record| record.generation_id == input.generation_id)); - let archived_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read archived ledger") - .expect("archived ledger exists"); - assert_eq!(archived_ledger.phase, GenerationLedgerPhase::Archived); - assert_eq!(archived_ledger.archived_at, Some(archived.archived_at)); - - let repeated = archive_failed_asset_canvas_generation_at(directory.path(), &archive_input) - .await - .expect("repeat archive is idempotent"); - assert_eq!(repeated.archived_at, archived.archived_at); - assert_eq!(repeated.draft.revision, archived.draft.revision); - assert!(!repeated - .draft - .generations - .iter() - .any(|record| record.generation_id == input.generation_id)); - - let recovery = with_test_external_editor_credentials( - "http://127.0.0.1:9", - "archive-recovery-api-key", - async { - recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - }, - ) - .await - .expect("reload skips archived generation"); - assert!(recovery.result.resumed_generation_ids.is_empty()); - let reloaded = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("reload archived draft") - .expect("archived draft remains"); - assert_eq!(reloaded.revision, archived.draft.revision); - assert!(!reloaded - .generations - .iter() - .any(|record| record.generation_id == input.generation_id)); - - let mut pending = archived_ledger.clone(); - pending.phase = GenerationLedgerPhase::ArchivePending; - pending.archived_at = Some(archived.archived_at); - let mut pending_draft = archived.draft.clone(); - pending_draft.generations.push(AssetCanvasGenerationRecord { - generation_id: input.generation_id.clone(), - intent_id: input.intent_id.clone(), - phase: AssetCanvasGenerationStatus::Failed, - reference_resource_ids: Vec::new(), - output_asset_id: None, - source_layer_id: None, - placeholder: None, - error_code: Some("generation-rejected".to_string()), - created_at: 1, - updated_at: 1, - idempotency_key: None, - status: None, - prompt: None, - operation_id: None, - output_media_ids: Vec::new(), - }); - pending_draft.revision += 1; - write_asset_canvas_draft_locked(directory.path(), &pending_draft) - .expect("write pending archive draft"); - write_generation_ledger(directory.path(), &mut pending) - .expect("write pending archive ledger"); - let pending_recovery = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("recover pending archive"); - assert!(pending_recovery.result.resumed_generation_ids.is_empty()); - assert_eq!( - read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered pending archive ledger") - .expect("recovered pending archive ledger exists") - .phase, - GenerationLedgerPhase::Archived - ); - assert!( - !read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered pending archive draft") - .expect("recovered pending archive draft exists") - .generations - .iter() - .any(|record| record.generation_id == input.generation_id) - ); - } - - #[tokio::test] - async fn generation_archive_rejects_running_and_reconciliation_required_ledgers() { - for (reconciliation, expected_message) in [ - (false, "只有明确失败的素材画布生成可以归档"), - ( - true, - "reconciliation-required: 结果未知的素材画布生成不能归档", - ), - ] { - let project_id = if reconciliation { - "asset-canvas-reconciliation-archive-project" - } else { - "asset-canvas-running-archive-project" - }; - let (directory, draft) = - create_generation_fixture(project_id, "非明确失败生成拒绝归档测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "创建一个不能归档的测试图片", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) - .expect("prepare non-failed ledger"); - if reconciliation { - mark_generation_error( - directory.path(), - &mut ledger, - true, - "submit-result-unknown", - &mut |_| {}, - ) - .expect("persist reconciliation generation"); - } else { - set_private_phase( - directory.path(), - &mut ledger, - GenerationLedgerPhase::Running, - None, - ) - .expect("persist running ledger"); - publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) - .expect("persist running public generation"); - } - let current_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read non-failed draft") - .expect("non-failed draft exists"); - let error = archive_failed_asset_canvas_generation_at( - directory.path(), - &ArchiveAssetCanvasGenerationInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - expected_draft_revision: current_draft.revision, - draft_id: draft.draft_id.clone(), - generation_id: input.generation_id.clone(), - }, - ) - .await - .expect_err("non-failed generation must not archive"); - assert_eq!(error, expected_message); - let persisted = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read rejected archive ledger") - .expect("rejected archive ledger remains"); - assert_eq!(persisted.archived_at, None); - assert_eq!( - persisted.phase, - if reconciliation { - GenerationLedgerPhase::ReconciliationRequired - } else { - GenerationLedgerPhase::Running - } - ); - } - } - - #[tokio::test] - async fn local_refine_uploads_confirms_then_stages_candidate_and_preserves_the_source_asset() { - let project_id = "asset-canvas-reference-upload-project"; - let project_name = "素材画布本地图片精修测试"; - let (directory, draft, source_png) = - create_refine_generation_fixture(project_id, project_name); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind refine generation server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("refine generation address") - ); - let upload_url = format!("{base_url}/oss-upload"); - let signed_url = format!("{base_url}/refined.png"); - let result_png = test_png_with_color([218, 42, 64, 255]); - let source_image = - image::load_from_memory(&source_png).expect("decode refine source image"); - let source_width = source_image.width(); - let source_height = source_image.height(); - let mut input = generation_input( - directory.path(), - project_id, - &draft, - "把人物头发变成红色,保持其它内容不变", - ); - input.asset_name = "红发角色".to_string(); - let reference_sha256 = asset_canvas_sha256(&source_png); - let expected_object_key = format!( - "generated-character-drafts/editor/asset-canvas-references/{}/{}/{}/reference-{}.png", - project_id, "source-character", reference_sha256, reference_sha256 - ); - let server_object_key = expected_object_key.clone(); - let server_signed_url = signed_url.clone(); - let server_result_png = result_png.clone(); - let source_png_len = source_png.len(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set refine generation fixture nonblocking"); - for request_index in 0..10 { - let mut stream = accept_generation_fixture_connection( - &listener, - "refine generation fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture refine generation request"); - if request.starts_with("GET /api/external/v1/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "remote-project", - "title": project_name, - }]}}), - ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "remote-folder", - "label": project_name, - }]}}}), - ); - } else if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"upload": { - "host": upload_url.clone(), - "bucket": "stable-private-bucket", - "objectKey": server_object_key.clone(), - "successActionStatus": 204, - "maxSizeBytes": source_png_len, - "formFields": { - "key": server_object_key.clone(), - "success_action_status": "204", - "Content-Type": "image/png" - } - }}}), - ); - } else if request.starts_with("POST /oss-upload ") { - write_empty_response(&mut stream, "204 No Content"); - } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") - || request.starts_with("POST /api/assets/objects/confirm ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"assetObject": { - "objectKey": server_object_key.clone(), - "assetObjectId": "confirmed-reference-object" - }}}), - ); - } else if request - .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") - || request.starts_with("POST /api/editor/projects/remote-project/resources ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"resource": { - "resourceId": "editor-resource-source", - "projectId": "remote-project", - "objectKey": server_object_key.clone(), - "assetObjectId": "confirmed-reference-object" - }}}), - ); - } else if request.starts_with("POST /api/external/v1/editor/images/edits ") { - write_json( - &mut stream, - "202 Accepted", - serde_json::json!({"data": { - "operationId": "refine-operation", - "status": "queued", - "pollAfterMs": 0 - }}), - ); - } else if request.starts_with("GET /api/external/v1/generations/refine-operation ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "refine-operation", - "status": "completed", - "pollAfterMs": 0, - "result": {"resource": { - "resourceId": "refined-remote-resource", - "objectKey": "generated/refined-result.png", - "assetObjectId": "refined-remote-object" - }} - }}), - ); - } else if request.starts_with("GET /api/external/v1/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": server_signed_url.clone()}}), - ); - } else if request.starts_with("GET /refined.png ") { - write_png(&mut stream, &server_result_png); - } else { - panic!("unexpected refine generation request: {request}"); - } - } - }); - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - "refine-private-api-key", - None, - "remote-project", - "remote-folder", - ); - let execution = - with_test_external_editor_credentials(&base_url, "refine-private-api-key", async { - generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await - }) - .await - .expect("upload local source and complete refine generation"); - server.join().expect("join refine generation server"); - - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 10); - let ticket_index = requests - .iter() - .position(|request| { - request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") - }) - .expect("ticket request exists"); - let upload_index = requests - .iter() - .position(|request| request.starts_with("POST /oss-upload ")) - .expect("OSS upload request exists"); - let confirm_index = requests - .iter() - .position(|request| { - request.starts_with("POST /api/assets/objects/confirm ") - || request.starts_with("POST /api/external/v1/assets/objects/confirm ") - }) - .expect("confirm request exists"); - let submit_index = requests - .iter() - .position(|request| request.starts_with("POST /api/external/v1/editor/images/edits ")) - .expect("refine submit request exists"); - let register_index = requests - .iter() - .position(|request| { - request.starts_with("POST /api/editor/projects/remote-project/resources ") - || request.starts_with( - "POST /api/external/v1/editor/projects/remote-project/resources ", - ) - }) - .expect("project resource registration exists"); - assert!(ticket_index < upload_index && upload_index < confirm_index); - assert!(confirm_index < register_index && register_index < submit_index); - assert!(requests[ticket_index].contains("\"legacyPrefix\":\"generated-character-drafts\"")); - assert!(requests[ticket_index].contains(&format!( - "\"pathSegments\":[\"editor\",\"asset-canvas-references\",\"{}\",\"{}\",\"{}\"]", - project_id, "source-character", reference_sha256 - ))); - assert!(requests[confirm_index].contains(&expected_object_key)); - assert!(requests[register_index] - .contains(&format!("\"imageSrc\":\"/{}\"", expected_object_key))); - assert!(requests[register_index].contains(&format!("\"width\":{source_width}"))); - assert!(requests[register_index].contains(&format!("\"height\":{source_height}"))); - assert!(requests[register_index].contains("\"assetKind\":\"illustration\"")); - assert!(requests[confirm_index].contains("\"assetKind\":\"image\"")); - assert!(requests[submit_index].contains("\"sourceReferenceId\":\"editor-resource-source\"")); - assert!(!requests[submit_index].contains("\"assetKind\"")); - assert!(!requests[submit_index].contains("sourceImageSrc")); - assert!(!requests[submit_index].contains("sourceResourceId")); - - assert!(execution.result.commit.is_none()); - assert_eq!(execution.result.images.len(), 1); - let manifest = validate_asset_canvas_project_identity(directory.path(), project_id) - .expect("read unchanged manifest"); - assert_eq!(manifest.assets.len(), 1); - let source = manifest - .assets - .iter() - .find(|asset| asset.id == "source-character") - .expect("source asset remains in manifest"); - assert_eq!(source.local_path, "assets/source.png"); - assert_eq!( - fs::read(directory.path().join(&source.local_path)).expect("read preserved source"), - source_png - ); - assert_eq!(execution.result.draft.canvas.layers.len(), 2); - assert!(execution - .result - .draft - .canvas - .layers - .iter() - .any(|layer| matches!( - layer.media_ref, - AssetCanvasMediaRef::DraftMedia { ref media_type, .. } if media_type == "image/png" - ))); - let ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read refine ledger") - .expect("refine ledger exists"); - assert_eq!(ledger.operation_id.as_deref(), Some("refine-operation")); - assert_eq!( - ledger.resolved_reference_ids, - vec!["editor-resource-source".to_string()] - ); - assert!(ledger.reference_states.iter().all(|state| { - state.upload_bucket.is_none() - && state.upload_object_key.is_none() - && state.upload_completed - })); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn concurrent_generations_register_the_same_reference_binding_once() { - let project_id = "asset-canvas-concurrent-reference-binding"; - let project_name = "素材画布并发参考资源绑定测试"; - let (directory, draft, source_png) = - create_refine_generation_fixture(project_id, project_name); - let listener = - TcpListener::bind("127.0.0.1:0").expect("bind concurrent reference binding server"); - let base_url = format!( - "http://{}", - listener - .local_addr() - .expect("concurrent reference binding address") - ); - let upload_url = format!("{base_url}/oss-upload"); - let source_png_len = source_png.len(); - let (ticket_sender, ticket_receiver) = mpsc::channel(); - let (release_sender, release_receiver) = mpsc::channel(); - let (request_sender, request_receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set concurrent reference fixture nonblocking"); - for request_index in 0..4 { - let mut stream = accept_generation_fixture_connection( - &listener, - "concurrent reference binding fixture", - request_index, - ); - let request = read_http_request(&mut stream); - request_sender - .send(request.clone()) - .expect("capture concurrent reference request"); - if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { - ticket_sender - .send(()) - .expect("signal reference ticket request"); - release_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("release reference ticket response"); - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"upload": { - "host": upload_url.clone(), - "bucket": "concurrent-reference-bucket", - "objectKey": "concurrent/reference-source.png", - "successActionStatus": 204, - "maxSizeBytes": source_png_len, - "formFields": { - "key": "concurrent/reference-source.png", - "success_action_status": "204", - "Content-Type": "image/png" - } - }}}), - ); - } else if request.starts_with("POST /oss-upload ") { - write_empty_response(&mut stream, "204 No Content"); - } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"assetObject": { - "objectKey": "concurrent/reference-source.png", - "assetObjectId": "concurrent-reference-object" - }}}), - ); - } else if request - .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"resource": { - "resourceId": "editor-resource-concurrent-source", - "projectId": "remote-project", - "objectKey": "concurrent/reference-source.png", - "assetObjectId": "concurrent-reference-object" - }}}), - ); - } else { - panic!("unexpected concurrent reference request: {request}"); - } - } - }); - - let api_key = "concurrent-reference-private-key"; - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - api_key, - None, - "remote-project", - "remote-folder", - ); - let first_input = generation_input( - directory.path(), - project_id, - &draft, - "第一个 generation 使用同一张源图", - ); - let second_input = generation_input( - directory.path(), - project_id, - &draft, - "第二个 generation 并发使用同一张源图", - ); - assert_ne!(first_input.generation_id, second_input.generation_id); - let mut first_ledger = - validate_and_prepare_ledger(directory.path(), &first_input, &base_url, None) - .expect("prepare first concurrent reference ledger"); - let mut second_ledger = - validate_and_prepare_ledger(directory.path(), &second_input, &base_url, None) - .expect("prepare second concurrent reference ledger"); - for ledger in [&mut first_ledger, &mut second_ledger] { - ledger.canvas_context = Some(PrivateCanvasContext { - project_id: "remote-project".to_string(), - asset_folder_id: "remote-folder".to_string(), - canvas_name: project_name.to_string(), - }); - write_generation_ledger(directory.path(), ledger) - .expect("persist concurrent reference ledger context"); - } - - let barrier = Arc::new(tokio::sync::Barrier::new(3)); - let first_root = directory.path().to_path_buf(); - let first_base_url = base_url.clone(); - let first_barrier = Arc::clone(&barrier); - let first = tokio::spawn(async move { - first_barrier.wait().await; - let result = with_test_external_editor_credentials( - &first_base_url, - api_key, - ensure_reference_states( - &first_root, - &mut first_ledger, - &reqwest::Client::new(), - &first_base_url, - &CanvasGenerationApiMode { - api_key: api_key.to_string(), - }, - None, - ), - ) - .await; - (result, first_ledger) - }); - let second_root = directory.path().to_path_buf(); - let second_base_url = base_url.clone(); - let second_barrier = Arc::clone(&barrier); - let second = tokio::spawn(async move { - second_barrier.wait().await; - let result = with_test_external_editor_credentials( - &second_base_url, - api_key, - ensure_reference_states( - &second_root, - &mut second_ledger, - &reqwest::Client::new(), - &second_base_url, - &CanvasGenerationApiMode { - api_key: api_key.to_string(), - }, - None, - ), - ) - .await; - (result, second_ledger) - }); - barrier.wait().await; - ticket_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("one generation reaches the reference ticket endpoint"); - tokio::time::sleep(Duration::from_millis(50)).await; - release_sender - .send(()) - .expect("release single reference ticket response"); - - let (first_result, first_ledger) = first.await.expect("join first reference generation"); - first_result.expect("first concurrent reference generation succeeds"); - let (second_result, second_ledger) = - second.await.expect("join second reference generation"); - second_result.expect("second concurrent reference generation succeeds"); - server.join().expect("join concurrent reference server"); - - for ledger in [&first_ledger, &second_ledger] { - assert_eq!( - ledger.resolved_reference_ids, - vec!["editor-resource-concurrent-source".to_string()] - ); - } - let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 4); - for prefix in [ - "POST /api/external/v1/assets/direct-upload-tickets ", - "POST /oss-upload ", - "POST /api/external/v1/assets/objects/confirm ", - "POST /api/external/v1/editor/projects/remote-project/resources ", - ] { - assert_eq!( - requests - .iter() - .filter(|request| request.starts_with(prefix)) - .count(), - 1, - "reference side effect must execute once: {prefix}" - ); - } - let access = ExternalEditorBindingAccess::new(&base_url, api_key, None) - .expect("build concurrent reference binding access"); - let principal = external_editor_binding_principal(&access) - .expect("build concurrent reference principal"); - let source_identity = new_external_editor_source_identity( - "source-character", - &asset_canvas_sha256(&source_png), - "image/png", - "illustration", - ) - .expect("build concurrent source identity"); - let binding = read_external_editor_resource_binding_at( - directory.path(), - project_id, - &principal, - "remote-project", - &source_identity, - ) - .expect("read concurrent reference binding") - .expect("concurrent reference binding exists"); - assert_eq!( - binding.remote_resource_id.as_deref(), - Some("editor-resource-concurrent-source") - ); - } - - #[tokio::test] - async fn manifest_reference_binding_is_account_scoped_and_source_identity_sensitive() { - let project_id = "asset-canvas-account-reference-binding"; - let project_name = "素材画布账号参考资源绑定测试"; - let (directory, draft, source_png) = - create_refine_generation_fixture(project_id, project_name); - let source_sha256 = asset_canvas_sha256(&source_png); - let source_identity = new_external_editor_source_identity( - "source-character", - &source_sha256, - "image/png", - "illustration", - ) - .expect("build source identity"); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind account binding server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("account binding address") - ); - let upload_url = format!("{base_url}/oss-upload"); - - let principal_a = { - let _session = crate::platform_session::install_test_platform_session( - "reference-owner-a", - "reference-token-a", - &base_url, - ); - let session = crate::platform_session::current_platform_session() - .expect("account A session snapshot"); - let access = - ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) - .expect("account A binding access"); - let principal = - external_editor_binding_principal(&access).expect("account A principal"); - let project_binding = new_external_editor_project_binding( - project_id, - &principal, - "remote-project-a", - "remote-folder-a", - unix_timestamp(), - ) - .expect("account A project binding"); - write_external_editor_project_binding_at(directory.path(), &project_binding) - .expect("persist account A project binding"); - let resource_binding = new_external_editor_resource_binding( - project_id, - &principal, - "remote-project-a", - &source_identity, - Some("editor-resource-account-a"), - "accounts/a/source.png", - "asset-object-account-a", - Some(4), - Some(3), - unix_timestamp(), - ) - .expect("account A resource binding"); - write_external_editor_resource_binding_at(directory.path(), &resource_binding) - .expect("persist account A resource binding"); - principal - }; - - let different_sha_identity = new_external_editor_source_identity( - "source-character", - &asset_canvas_sha256(&test_png_with_color([39, 132, 76, 255])), - "image/png", - "illustration", - ) - .expect("build changed SHA identity"); - let different_kind_identity = new_external_editor_source_identity( - "source-character", - &source_sha256, - "image/png", - "character-art", - ) - .expect("build changed kind identity"); - assert!(read_external_editor_resource_binding_at( - directory.path(), - project_id, - &principal_a, - "remote-project-a", - &different_sha_identity, - ) - .expect("read changed SHA binding") - .is_none()); - assert!(read_external_editor_resource_binding_at( - directory.path(), - project_id, - &principal_a, - "remote-project-a", - &different_kind_identity, - ) - .expect("read changed kind binding") - .is_none()); - - let server_source_png_len = source_png.len(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set account binding server nonblocking"); - for request_index in 0..4 { - let mut stream = accept_generation_fixture_connection( - &listener, - "account binding fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture account binding request"); - if request.starts_with("POST /api/assets/direct-upload-tickets ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"upload": { - "host": upload_url.clone(), - "bucket": "account-b-private-bucket", - "objectKey": "accounts/b/source.png", - "successActionStatus": 204, - "maxSizeBytes": server_source_png_len, - "formFields": { - "key": "accounts/b/source.png", - "success_action_status": "204", - "Content-Type": "image/png" - } - }}}), - ); - } else if request.starts_with("POST /oss-upload ") { - write_empty_response(&mut stream, "204 No Content"); - } else if request.starts_with("POST /api/assets/objects/confirm ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"assetObject": { - "objectKey": "accounts/b/source.png", - "assetObjectId": "asset-object-account-b" - }}}), - ); - } else if request - .starts_with("POST /api/editor/projects/remote-project-b/resources ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"resource": { - "resourceId": "editor-resource-account-b", - "projectId": "remote-project-b", - "objectKey": "accounts/b/source.png", - "assetObjectId": "asset-object-account-b" - }}}), - ); - } else { - panic!("unexpected account binding request: {request}"); - } - } - }); - - let mut account_b_input = generation_input( - directory.path(), - project_id, - &draft, - "账号 B 使用账号隔离后的源图片", - ); - account_b_input.asset_kind = "ui-design".to_string(); - let mut account_b_ledger = - validate_and_prepare_ledger(directory.path(), &account_b_input, &base_url, None) - .expect("prepare account B ledger"); - account_b_ledger.access_scheme = Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME.to_string()); - account_b_ledger.canvas_context = Some(PrivateCanvasContext { - project_id: "remote-project-b".to_string(), - asset_folder_id: "remote-folder-b".to_string(), - canvas_name: project_name.to_string(), - }); - account_b_ledger.platform_owner_user_id = Some("reference-owner-b".to_string()); - account_b_ledger.platform_owner_api_base_url = Some(base_url.clone()); - write_generation_ledger(directory.path(), &mut account_b_ledger) - .expect("persist account B ledger context"); - - let principal_b = { - let _session = crate::platform_session::install_test_platform_session( - "reference-owner-b", - "reference-token-b", - &base_url, - ); - let session = crate::platform_session::current_platform_session() - .expect("account B session snapshot"); - let access = - ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) - .expect("account B binding access"); - let principal = - external_editor_binding_principal(&access).expect("account B principal"); - let project_binding = new_external_editor_project_binding( - project_id, - &principal, - "remote-project-b", - "remote-folder-b", - unix_timestamp(), - ) - .expect("account B project binding"); - write_external_editor_project_binding_at(directory.path(), &project_binding) - .expect("persist account B project binding"); - ensure_reference_states( - directory.path(), - &mut account_b_ledger, - &reqwest::Client::new(), - &base_url, - &CanvasGenerationApiMode { - api_key: session.access_token.clone(), - }, - Some(&session), - ) - .await - .expect("account B re-registers the local manifest reference"); - principal - }; - server.join().expect("join account binding server"); - - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 4); - assert!(requests.iter().all(|request| { - !request.contains("remote-project-a") - && !request.contains("editor-resource-account-a") - && !request.contains("asset-object-account-a") - && !request.contains("accounts/a/source.png") - })); - let register_request = requests - .iter() - .find(|request| { - request.starts_with("POST /api/editor/projects/remote-project-b/resources ") - }) - .expect("account B registration request"); - assert!(register_request.contains("\"assetKind\":\"illustration\"")); - assert!(!register_request.contains("\"assetKind\":\"ui-design\"")); - assert_eq!( - account_b_ledger.resolved_reference_ids, - vec!["editor-resource-account-b".to_string()] - ); - let account_b_binding = read_external_editor_resource_binding_at( - directory.path(), - project_id, - &principal_b, - "remote-project-b", - &source_identity, - ) - .expect("read account B resource binding") - .expect("account B resource binding exists"); - assert_eq!( - account_b_binding.remote_resource_id.as_deref(), - Some("editor-resource-account-b") - ); - - let account_a_input = generation_input( - directory.path(), - project_id, - &draft, - "切回账号 A 复用 A 自己的源图片绑定", - ); - let mut account_a_ledger = - validate_and_prepare_ledger(directory.path(), &account_a_input, &base_url, None) - .expect("prepare account A reuse ledger"); - account_a_ledger.access_scheme = Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME.to_string()); - account_a_ledger.canvas_context = Some(PrivateCanvasContext { - project_id: "remote-project-a".to_string(), - asset_folder_id: "remote-folder-a".to_string(), - canvas_name: project_name.to_string(), - }); - account_a_ledger.platform_owner_user_id = Some("reference-owner-a".to_string()); - account_a_ledger.platform_owner_api_base_url = Some(base_url.clone()); - write_generation_ledger(directory.path(), &mut account_a_ledger) - .expect("persist account A reuse ledger context"); - { - let _session = crate::platform_session::install_test_platform_session( - "reference-owner-a", - "reference-token-a", - &base_url, - ); - let session = crate::platform_session::current_platform_session() - .expect("restored account A session snapshot"); - ensure_reference_states( - directory.path(), - &mut account_a_ledger, - &reqwest::Client::new(), - &base_url, - &CanvasGenerationApiMode { - api_key: session.access_token.clone(), - }, - Some(&session), - ) - .await - .expect("account A reuses its own binding without remote traffic"); - } - assert_eq!( - account_a_ledger.resolved_reference_ids, - vec!["editor-resource-account-a".to_string()] - ); - } - - #[tokio::test] - async fn reference_ticket_failure_is_stage_specific_and_never_submits_generation() { - let project_id = "asset-canvas-ticket-failure-project"; - let project_name = "素材画布上传票据失败测试"; - let (directory, draft, _) = create_refine_generation_fixture(project_id, project_name); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind ticket failure server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("ticket failure address") - ); - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - "ticket-failure-private-api-key", - None, - "remote-project", - "remote-folder", - ); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set ticket failure fixture nonblocking"); - for request_index in 0..3 { - let mut stream = accept_generation_fixture_connection( - &listener, - "ticket failure fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture ticket failure request"); - if request.starts_with("GET /api/external/v1/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "remote-project", - "title": project_name, - }]}}), - ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "remote-folder", - "label": project_name, - }]}}}), - ); - } else if request.starts_with("POST /api/assets/direct-upload-tickets ") - || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") - { - write_json( - &mut stream, - "400 Bad Request", - serde_json::json!({"error": {"code": "BAD_REQUEST"}}), - ); - } else { - panic!("unexpected ticket failure request: {request}"); - } - } - }); - let input = generation_input( - directory.path(), - project_id, - &draft, - "这段提示词不得进入公开错误", - ); - - let error = with_test_external_editor_credentials( - &base_url, - "ticket-failure-private-api-key", - async { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await }, - ) - .await - .err() - .expect("ticket failure must stop generation"); - server.join().expect("join ticket failure server"); - - assert_eq!(error, "参考资源上传凭证申请失败,未提交生成"); - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 3); - assert!(!requests.iter().any(|request| { - request.starts_with("POST /api/external/v1/editor/images/edits ") - || request.starts_with("POST /api/external/v1/editor/images/generations ") - })); - let ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read ticket failure ledger") - .expect("ticket failure ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::Failed); - assert_eq!( - ledger.error_code.as_deref(), - Some("reference-ticket-failed") - ); - assert!(ledger.operation_id.is_none()); - assert!(ledger.request_body_json.is_none()); - let manifest = - current_asset_canvas_manifest(directory.path()).expect("read source manifest"); - assert_eq!(manifest.assets.len(), 1); - assert_eq!(manifest.assets[0].id, "source-character"); - } - - #[test] - fn reference_preparation_error_codes_remain_stage_specific_and_sanitized() { - for (error, code, message) in [ - ( - ReferencePreparationError::MaterialInvalid, - "reference-material-invalid", - "参考资源读取或校验失败,未提交生成", - ), - ( - ReferencePreparationError::TicketFailed, - "reference-ticket-failed", - "参考资源上传凭证申请失败,未提交生成", - ), - ( - ReferencePreparationError::ObjectUploadFailed, - "reference-object-upload-failed", - "参考资源上传失败,未提交生成", - ), - ( - ReferencePreparationError::ConfirmFailed, - "reference-confirm-failed", - "参考资源对象确认失败,未提交生成", - ), - ] { - assert_eq!(error.code(), code); - assert_eq!(sanitized_generation_error(code), message); - } - assert_eq!( - ReferencePreparationError::from_http_error( - "创建参考资源上传凭证失败:HTTP 401", - ReferencePreparationError::TicketFailed, - ), - ReferencePreparationError::AuthenticationRequired - ); - } - - #[tokio::test] - async fn asset_canvas_service_identity_accepted_legacy_key_requires_confirmation_then_uses_get_only( - ) { - let project_id = "phase-five-recovery-project"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五 accepted 恢复测试"); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind recovery server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("recovery address") - ); - let signed_url = format!("{base_url}/recovered.png"); - let png = test_png(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set accepted recovery fixture nonblocking"); - for request_index in 0..3 { - let mut stream = accept_generation_fixture_connection( - &listener, - "accepted recovery fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture recovery request"); - if request - .starts_with("GET /api/external/v1/generations/accepted-operation-phase-five ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "accepted-operation-phase-five", - "status": "completed", - "pollAfterMs": 0, - "result": {"resource": { - "resourceId": "recovered-resource", - "objectKey": "generated/recovered.png", - "assetObjectId": "recovered-object", - }} - }}), - ); - } else if request.starts_with("GET /api/external/v1/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": signed_url}}), - ); - } else if request.starts_with("GET /recovered.png ") { - write_png(&mut stream, &png); - } else { - panic!("unexpected recovery request: {request}"); - } - } - }); - let old_api_key = "phase-five-recovery-old-key"; - let api_key = "phase-five-recovery-rotated-key"; - let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); - ledger.api_identity_scheme = None; - ledger.api_identity_fingerprint = Some( - platform_art_generation_legacy_external_configuration_fingerprint( - &base_url, - old_api_key, - ), - ); - let generation_id = ledger.generation_id.clone(); - let operation_id = ledger.operation_id.clone(); - write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); - - let input = RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }; - let blocked = with_test_external_editor_credentials(&base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await - }) - .await - .expect("request explicit service identity confirmation"); - assert!(blocked.result.resumed_generation_ids.is_empty()); - assert_eq!(blocked.result.service_identity_confirmations.len(), 1); - assert!(receiver.try_recv().is_err(), "确认前不得访问网络"); - let confirmation = blocked.result.service_identity_confirmations[0].clone(); - assert_eq!(confirmation.generation_id, generation_id); - assert_eq!(confirmation.operation_id, operation_id); - assert_eq!(confirmation.operation_state, "accepted"); - assert_eq!(confirmation.service_origin, base_url); - - let confirmed = with_test_external_editor_credentials(&base_url, api_key, async { - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: generation_id.clone(), - operation_id: operation_id.clone(), - challenge: confirmation.challenge, - }, - ) - .await - }) - .await - .expect("confirm the current service for the legacy operation"); - assert_eq!( - confirmed.identity_scheme, - PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME - ); - - let first = with_test_external_editor_credentials(&base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await - }) - .await - .expect("recover accepted generation after confirmation"); - server.join().expect("join recovery server"); - assert_eq!( - first.result.resumed_generation_ids, - vec![generation_id.clone()] - ); - assert!(first.result.service_identity_confirmations.is_empty()); - assert!(first.events.is_empty()); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered candidate draft") - .expect("recovered candidate draft exists"); - assert!(recovered_draft.generations.iter().any(|record| { - record.generation_id == generation_id - && record.phase == AssetCanvasGenerationStatus::CandidateReady - && record.output_asset_id.is_none() - })); - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 3); - assert!(requests.iter().all(|request| request.starts_with("GET "))); - - let second = with_test_external_editor_credentials(&base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &input, |_| {}).await - }) - .await - .expect("ignore already committed generation"); - assert!(second.result.resumed_generation_ids.is_empty()); - assert!(second.result.service_identity_confirmations.is_empty()); - assert!(second.events.is_empty()); - let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); - assert_eq!(manifest.assets.len(), 0); - } - - #[test] - fn canvas_service_identity_helper_rejects_switch_without_writing_mixed_owner_fingerprint() { - let project_id = "asset-canvas-identity-switch"; - let (directory, draft) = create_generation_fixture(project_id, "素材画布切号指纹测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "冻结账号 A 后切换账号 B", - ); - let base_url = "https://dev.genarrative.world"; - let _session = crate::platform_session::install_test_platform_session( - "canvas-owner-a", - "canvas-token-a", - base_url, - ); - let frozen_a = current_platform_session().expect("freeze canvas owner A"); - let mut ledger = - validate_and_prepare_ledger(directory.path(), &input, base_url, Some(&frozen_a)) - .expect("prepare generation ledger"); - replace_platform_session_for_gui_owner("canvas-owner-b", "canvas-token-b", base_url, 2) - .expect("switch global canvas session to owner B"); - let api_mode = CanvasGenerationApiMode { - api_key: frozen_a.access_token.clone(), - }; - - let error = prepare_generation_service_identity( - directory.path(), - &mut ledger, - base_url, - &api_mode, - Some(&frozen_a), - ) - .expect_err("switched owner must stop before canvas identity ledger mutation"); - assert!(error.contains("authentication-required"), "{error}"); - assert_eq!( - ledger.platform_owner_user_id.as_deref(), - Some("canvas-owner-a") - ); - assert_eq!( - ledger.api_identity_fingerprint, - Some(platform_art_generation_external_service_fingerprint( - base_url, - Some(&frozen_a), - )) - ); - } - - #[tokio::test] - async fn canvas_identity_confirm_rejects_current_non_owner_forged_challenge_without_mutation() { - let project_id = "asset-canvas-identity-owner"; - let (directory, draft) = - create_generation_fixture(project_id, "素材画布身份确认 owner 隔离测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "验证非 owner 不能确认伪造 challenge", - ); - let api_base_url = "https://canvas-identity-owner.example.test"; - let _session = install_test_platform_session( - "canvas-identity-owner-a", - "canvas-identity-token-a", - api_base_url, - ); - let owner_a = current_platform_session().expect("canvas identity owner A"); - let mut ledger = - validate_and_prepare_ledger(directory.path(), &input, api_base_url, Some(&owner_a)) - .expect("prepare canvas identity owner ledger"); - let owner_b = PlatformSessionSnapshot { - user_id: "canvas-identity-owner-b".to_string(), - access_token: "canvas-identity-token-b".to_string(), - api_base_url: owner_a.api_base_url.clone(), - generation: owner_a.generation + 1, - }; - let challenge = new_asset_canvas_token().expect("create forged canvas challenge"); - let ledger_snapshot_sha256 = generation_service_identity_snapshot_sha256(&ledger) - .expect("canvas owner ledger snapshot"); - ledger.service_identity_confirmation = Some(PrivateServiceIdentityConfirmation { - challenge: challenge.clone(), - service_fingerprint: platform_art_generation_external_service_fingerprint( - api_base_url, - Some(&owner_b), - ), - ledger_snapshot_sha256, - expires_at: asset_canvas_now() + 60_000, - }); - write_generation_ledger(directory.path(), &mut ledger) - .expect("write canvas owner A ledger"); - let ledger_path = directory - .path() - .join(generation_ledger_relative_path(&input.generation_id)); - let before = fs::read(&ledger_path).expect("read canvas owner A ledger"); - replace_platform_session_for_gui_owner( - &owner_b.user_id, - &owner_b.access_token, - &owner_b.api_base_url, - owner_b.generation, - ) - .expect("switch to canvas non-owner B"); - - let error = confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: input.generation_id.clone(), - operation_id: ledger.operation_id.clone(), - challenge, - }, - ) - .await - .expect_err("non-owner B must not confirm forged canvas challenge"); - assert!(error.contains("authentication-required"), "{error}"); - assert_eq!( - fs::read(&ledger_path).expect("reread after rejected canvas confirm"), - before - ); - } - - #[test] - fn canvas_first_ledger_write_lease_blocks_switch_and_rejects_stale_snapshot() { - let project_id = "asset-canvas-owner-lease"; - let (directory, draft) = - create_generation_fixture(project_id, "素材画布 owner 写入线性化测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "验证 owner 账本写入与切号线性化", - ); - let api_base_url = "https://canvas-owner-lease.example.test"; - let _session = install_test_platform_session( - "canvas-lease-owner-a", - "canvas-lease-token-a", - api_base_url, - ); - let frozen_a = current_platform_session().expect("freeze canvas lease owner A"); - let entered = Arc::new(std::sync::Barrier::new(2)); - let release = Arc::new(std::sync::Barrier::new(2)); - let (switched_sender, switched_receiver) = mpsc::channel(); - - std::thread::scope(|scope| { - let write_entered = Arc::clone(&entered); - let write_release = Arc::clone(&release); - let first_write = scope.spawn(|| { - install_asset_canvas_account_bound_write_test_barrier( - AssetCanvasAccountBoundWriteTestBarrier { - entered: write_entered, - release: write_release, - }, - ); - validate_and_prepare_ledger(directory.path(), &input, api_base_url, Some(&frozen_a)) - }); - entered.wait(); - let switch = scope.spawn(|| { - replace_platform_session_for_gui_owner( - "canvas-lease-owner-b", - "canvas-lease-token-b", - api_base_url, - frozen_a.generation + 1, - ) - .expect("switch canvas lease owner"); - switched_sender.send(()).expect("signal canvas switch"); - }); - assert!(switched_receiver - .recv_timeout(Duration::from_millis(100)) - .is_err()); - release.wait(); - first_write - .join() - .expect("join canvas first ledger write") - .expect("write canvas owner A under lease"); - switch.join().expect("join canvas owner switch"); - }); - switched_receiver - .recv_timeout(Duration::from_secs(2)) - .expect("canvas owner switch completes after write"); - let persisted = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read canvas owner ledger") - .expect("canvas owner ledger exists"); - assert_eq!( - persisted.platform_owner_user_id.as_deref(), - Some("canvas-lease-owner-a") - ); - assert_eq!( - persisted.platform_owner_api_base_url.as_deref(), - Some(api_base_url) - ); - assert_eq!( - persisted.access_scheme.as_deref(), - Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME) - ); - - let stale_input = generation_input( - directory.path(), - project_id, - &draft, - "旧 owner snapshot 不得写入", - ); - let stale_path = directory - .path() - .join(generation_ledger_relative_path(&stale_input.generation_id)); - let error = validate_and_prepare_ledger( - directory.path(), - &stale_input, - api_base_url, - Some(&frozen_a), - ) - .expect_err("stale canvas owner snapshot must fail before first write"); - assert!(error.contains("authentication-required"), "{error}"); - assert!(!stale_path.exists()); - } - - #[tokio::test] - async fn historical_unowned_canvas_recovery_is_rejected_without_network_or_mutation() { - let platform_session_guard = clear_test_platform_session(); - let project_id = "asset-canvas-unowned-recovery"; - let (directory, draft) = create_generation_fixture(project_id, "历史未归属画布恢复测试"); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind canvas zero-network fixture"); - let api_base_url = format!( - "http://{}", - listener - .local_addr() - .expect("canvas zero-network fixture address") - ); - let input = generation_input( - directory.path(), - project_id, - &draft, - "历史未归属账本不得被账号 B 认领", - ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input, &api_base_url, None) - .expect("prepare historical canvas fixture"); - ledger.access_scheme = None; - ledger.api_identity_scheme = None; - ledger.api_identity_fingerprint = None; - ledger.platform_owner_user_id = None; - ledger.platform_owner_api_base_url = None; - write_generation_ledger(directory.path(), &mut ledger) - .expect("write historical unowned canvas ledger"); - let ledger_path = directory - .path() - .join(generation_ledger_relative_path(&input.generation_id)); - let before = fs::read(&ledger_path).expect("read historical canvas ledger bytes"); - drop(platform_session_guard); - - let _session = install_test_platform_session( - "unowned-canvas-owner-b", - "unowned-canvas-token-b", - &api_base_url, - ); - let recovered = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("unowned canvas recovery must fail closed without exposing identity"); - assert!(recovered.result.resumed_generation_ids.is_empty()); - assert!(recovered.result.service_identity_confirmations.is_empty()); - assert!(recovered.events.is_empty()); - assert_eq!( - fs::read(&ledger_path).expect("reread rejected unowned canvas ledger"), - before - ); - listener - .set_nonblocking(true) - .expect("set canvas zero-network fixture nonblocking"); - assert!(matches!( - listener.accept(), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock - )); - } - - #[tokio::test] - async fn asset_canvas_service_identity_rejects_expired_and_stale_challenges() { - let project_id = "service-identity-stale-challenge"; - let (directory, draft) = create_generation_fixture(project_id, "服务身份陈旧挑战测试"); - let base_url = "https://editor.example.test"; - let old_api_key = "old-key"; - let api_key = "rotated-key"; - let mut ledger = accepted_ledger(project_id, &draft, base_url, old_api_key); - ledger.api_identity_scheme = None; - ledger.api_identity_fingerprint = Some( - platform_art_generation_legacy_external_configuration_fingerprint( - base_url, - old_api_key, - ), - ); - let generation_id = ledger.generation_id.clone(); - let operation_id = ledger.operation_id.clone(); - write_generation_ledger(directory.path(), &mut ledger).expect("write legacy ledger"); - let recover_input = RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }; - let first = with_test_external_editor_credentials(base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await - }) - .await - .expect("issue first challenge"); - let first_confirmation = first.result.service_identity_confirmations[0].clone(); - let mut expired = read_generation_ledger(directory.path(), &generation_id) - .expect("read ledger") - .expect("ledger exists"); - expired - .service_identity_confirmation - .as_mut() - .expect("confirmation exists") - .expires_at = asset_canvas_now(); - write_generation_ledger(directory.path(), &mut expired).expect("expire challenge"); - let expired_error = with_test_external_editor_credentials(base_url, api_key, async { - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - generation_id: generation_id.clone(), - operation_id: operation_id.clone(), - challenge: first_confirmation.challenge, - }, - ) - .await - }) - .await - .expect_err("expired challenge must fail closed"); - assert!(expired_error.contains("过期或上下文已变化")); - - let second = with_test_external_editor_credentials(base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await - }) - .await - .expect("rotate expired challenge"); - let second_confirmation = second.result.service_identity_confirmations[0].clone(); - let mut changed = read_generation_ledger(directory.path(), &generation_id) - .expect("read ledger") - .expect("ledger exists"); - changed.phase = GenerationLedgerPhase::Running; - write_generation_ledger(directory.path(), &mut changed).expect("change operation state"); - let stale_error = with_test_external_editor_credentials(base_url, api_key, async { - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id, - operation_id, - challenge: second_confirmation.challenge, - }, - ) - .await - }) - .await - .expect_err("ledger changes must invalidate the challenge"); - assert!(stale_error.contains("过期或上下文已变化")); - } - - #[tokio::test] - async fn asset_canvas_service_identity_rejects_service_address_change() { - let project_id = "service-identity-address-change"; - let (directory, draft) = create_generation_fixture(project_id, "服务身份地址变化测试"); - let original_base_url = "https://editor.example.test"; - let old_api_key = "old-key"; - let original_api_key = "rotated-key"; - let mut ledger = accepted_ledger(project_id, &draft, original_base_url, old_api_key); - ledger.api_identity_scheme = None; - ledger.api_identity_fingerprint = Some( - platform_art_generation_legacy_external_configuration_fingerprint( - original_base_url, - old_api_key, - ), - ); - let generation_id = ledger.generation_id.clone(); - let operation_id = ledger.operation_id.clone(); - write_generation_ledger(directory.path(), &mut ledger).expect("write legacy ledger"); - let blocked = - with_test_external_editor_credentials(original_base_url, original_api_key, async { - recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - }) - .await - .expect("issue service challenge"); - let confirmation = blocked.result.service_identity_confirmations[0].clone(); - let error = with_test_external_editor_credentials( - "https://replacement-editor.example.test", - "replacement-key", - async { - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id, - operation_id, - challenge: confirmation.challenge, - }, - ) - .await - }, - ) - .await - .expect_err("service address change must invalidate confirmation"); - assert!(error.contains("过期或上下文已变化")); - } - - #[tokio::test] - async fn asset_canvas_service_identity_prepared_confirmation_replays_frozen_post_exactly_once() - { - let project_id = "service-identity-prepared-replay"; - let (directory, draft) = - create_generation_fixture(project_id, "服务身份 prepared 重放测试"); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("prepared recovery address") - ); - let signed_url = format!("{base_url}/prepared.png"); - let png = test_png(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set prepared recovery fixture nonblocking"); - for request_index in 0..4 { - let mut stream = accept_generation_fixture_connection( - &listener, - "prepared recovery fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture prepared recovery request"); - if request.starts_with("POST /api/external/v1/editor/images/generations ") { - write_json( - &mut stream, - "202 Accepted", - serde_json::json!({"data": { - "operationId": "prepared-operation", - "status": "queued", - "pollAfterMs": 0 - }}), - ); - } else if request - .starts_with("GET /api/external/v1/generations/prepared-operation ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "prepared-operation", - "status": "completed", - "pollAfterMs": 0, - "result": {"resource": { - "resourceId": "prepared-resource", - "objectKey": "generated/prepared.png", - "assetObjectId": "prepared-object" - }} - }}), - ); - } else if request.starts_with("GET /api/external/v1/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": signed_url}}), - ); - } else if request.starts_with("GET /prepared.png ") { - write_png(&mut stream, &png); - } else { - panic!("unexpected prepared recovery request: {request}"); - } - } - }); - let old_api_key = "prepared-old-key"; - let api_key = "prepared-rotated-key"; - let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); - ledger.phase = GenerationLedgerPhase::Prepared; - ledger.operation_id = None; - ledger.poll_after_ms = None; - ledger.api_identity_scheme = None; - ledger.api_identity_fingerprint = Some( - platform_art_generation_legacy_external_configuration_fingerprint( - &base_url, - old_api_key, - ), - ); - let generation_id = ledger.generation_id.clone(); - let idempotency_key = ledger.idempotency_key.clone(); - let frozen_body = ledger - .request_body_json - .clone() - .expect("prepared body exists"); - write_generation_ledger(directory.path(), &mut ledger).expect("write prepared ledger"); - let recover_input = RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }; - let blocked = with_test_external_editor_credentials(&base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await - }) - .await - .expect("request prepared service confirmation"); - assert!(receiver.try_recv().is_err(), "确认前不得提交 prepared 请求"); - let confirmation = blocked.result.service_identity_confirmations[0].clone(); - with_test_external_editor_credentials(&base_url, api_key, async { - confirm_asset_canvas_generation_service_identity_at( - directory.path(), - &ConfirmAssetCanvasGenerationServiceIdentityInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - generation_id: generation_id.clone(), - operation_id: None, - challenge: confirmation.challenge, - }, - ) - .await - }) - .await - .expect("confirm prepared service identity"); - let recovered = with_test_external_editor_credentials(&base_url, api_key, async { - recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}).await - }) - .await - .expect("replay prepared request"); - server.join().expect("join prepared recovery server"); - assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 4); - let posts = requests - .iter() - .filter(|request| request.starts_with("POST ")) - .collect::>(); - assert_eq!(posts.len(), 1); - assert!(posts[0] - .to_ascii_lowercase() - .contains(&format!("idempotency-key: {idempotency_key}"))); - assert!(posts[0].ends_with(&frozen_body)); - } - - #[test] - fn refine_snapshot_uses_source_once_and_preserves_local_lineage_identity() { - let body_json = serde_json::json!({"prompt": "private"}).to_string(); - let source_resource_id = "local-asset:source".to_string(); - let now = asset_canvas_now(); - let ledger = AssetCanvasGenerationLedger { - schema_version: ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION.to_string(), - request_fingerprint: "b".repeat(64), - project_id: "refine-project".to_string(), - draft_id: Uuid::new_v4().to_string(), - intent: AssetCanvasIntent::Refine, - source_asset_id: Some("source".to_string()), - source_resource_id: Some(source_resource_id.clone()), - source_layer_id: None, - placeholder: Some(AssetCanvasGenerationPlaceholder { - x: 7_000.0, - y: 6_000.0, - width: 512.0, - height: 512.0, - }), - intent_id: Uuid::new_v4().to_string(), - generation_id: Uuid::new_v4().to_string(), - idempotency_key: Uuid::new_v4().to_string(), - commit_id: Uuid::new_v4().to_string(), - commit_idempotency_key: Uuid::new_v4().to_string(), - expected_host_revision: 0, - expected_draft_revision: 0, - current_draft_revision: Some(0), - prompt: "精修提示词".to_string(), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "illustration".to_string(), - asset_name: "精修结果".to_string(), - requested_reference_resource_ids: vec![ - source_resource_id.clone(), - "local-asset:style".to_string(), - ], - reference_states: vec![ - PrivateReferenceState { - resource_id: source_resource_id.clone(), - stable_reference: Some("editor-resource-source".to_string()), - asset_object_id: Some("source-object".to_string()), - upload_bucket: None, - upload_object_key: None, - upload_completed: true, - }, - PrivateReferenceState { - resource_id: "local-asset:style".to_string(), - stable_reference: Some("objects/style.png".to_string()), - asset_object_id: Some("style-object".to_string()), - upload_bucket: None, - upload_object_key: None, - upload_completed: true, - }, - ], - resolved_reference_ids: vec![ - "editor-resource-source".to_string(), - "objects/style.png".to_string(), - ], - reference_binding_repair_attempted: false, - access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), - api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), - api_identity_fingerprint: Some("configuration".to_string()), - platform_owner_user_id: None, - platform_owner_api_base_url: None, - service_identity_confirmation: None, - canvas_context: Some(PrivateCanvasContext { - project_id: "remote-project".to_string(), - asset_folder_id: "remote-folder".to_string(), - canvas_name: "精修画布".to_string(), - }), - endpoint: None, - request_body_sha256: Some(asset_canvas_sha256(body_json.as_bytes())), - request_body_json: Some(body_json), - phase: GenerationLedgerPhase::ReferencesPreparing, - operation_id: None, - poll_after_ms: None, - remote_result: None, - staged_image_token: Uuid::new_v4().to_string(), - staged_draft_revision: None, - candidate_media_ref: None, - candidate_layer_id: None, - candidate_layer_acknowledged_revision: None, - commit_result: None, - error_code: None, - archived_at: None, - created_at: now, - updated_at: now, - }; - let (endpoint, request) = - build_generation_request_snapshot(&ledger).expect("build refine request"); - let request: serde_json::Value = - serde_json::from_str(&request).expect("parse refine request"); - assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); - assert_eq!(request["sourceReferenceId"], "editor-resource-source"); - assert!(request.get("assetKind").is_none()); - assert!( - request.get("assetKind").is_none(), - "图片精修请求不得携带仅属于资源登记的 assetKind 字段" - ); - assert!(request.get("sourceImageSrc").is_none()); - assert!(request.get("sourceResourceId").is_none()); - assert_eq!( - request["referenceImageSrcs"], - serde_json::json!(["objects/style.png"]) - ); - assert_eq!( - ledger.requested_reference_resource_ids, - vec![source_resource_id, "local-asset:style".to_string()] - ); - } - - #[test] - fn editor_reference_asset_kind_maps_game_agent_kinds_to_quick_edit_taxonomy() { - for (asset_kind, expected) in [ - ("illustration", None), - ("game-art", None), - ("image", None), - ("character", Some("character")), - ("character-art", Some("character")), - ("icon", Some("icon")), - ("icon-spec", Some("icon-spec")), - ("art-spritesheet", Some("icon-spritesheet")), - ("icon-spritesheet", Some("icon-spritesheet")), - ("ui-prototype", Some("ui-design")), - ("ui-design", Some("ui-design")), - ("publication-material", Some("publication-material")), - ("game-background", Some("scene")), - ("scene", Some("scene")), - ("spec", Some("spec")), - ("future-game-kind", None), - ] { - assert_eq!( - editor_reference_asset_kind(asset_kind), - expected, - "unexpected editor resource kind mapping for {asset_kind}" - ); - assert_eq!( - editor_reference_object_asset_kind(asset_kind), - expected.unwrap_or("image"), - "unexpected editor object kind mapping for {asset_kind}" - ); - } - } - - #[test] - fn submit_bad_request_classification_is_specific_and_never_returns_remote_body() { - let unsupported_body = serde_json::json!({ - "error": { - "details": { - "provider": "editor-image-edit", - "message": "当前素材类型不支持图片快速编辑", - "assetKind": "private-game-kind", - } - } - }) - .to_string(); - let unsupported_code = classify_canvas_bad_request(&unsupported_body); - assert_eq!(unsupported_code, "unsupported-source-kind"); - assert!(!sanitized_generation_error(unsupported_code).contains("private-game-kind")); - - let source_body = serde_json::json!({ - "error": { - "details": { - "provider": "editor-image-edit", - "field": "sourceReferenceId", - "sourceReferenceId": "editor-resource-private-id", - "message": "资源不存在", - } - } - }) - .to_string(); - let source_code = classify_canvas_bad_request(&source_body); - assert_eq!(source_code, "source-reference-invalid"); - assert!(!sanitized_generation_error(source_code).contains("editor-resource-private-id")); - - let unknown_body = "unknown field `assetKind`; api_key=private-secret"; - let unknown_code = classify_canvas_bad_request(unknown_body); - assert_eq!(unknown_code, "invalid-generation-request"); - assert!(!sanitized_generation_error(unknown_code).contains("private-secret")); - } - - #[test] - fn private_generation_ledger_never_serializes_upload_credentials_or_provider_url() { - let project_id = "phase-five-private-upload-ledger"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五私有上传账本测试"); - let ticket = PrivateUploadTicket { - host: "https://private-upload.provider.example.test/signed".to_string(), - bucket: "stable-private-bucket".to_string(), - object_key: - "generated-character-drafts/editor/asset-canvas-references/project/reference.png" - .to_string(), - success_action_status: 204, - form_fields: BTreeMap::from([ - ( - "Authorization".to_string(), - "private-authorization".to_string(), - ), - ("policy".to_string(), "private-upload-policy".to_string()), - ( - "signature".to_string(), - "private-upload-signature".to_string(), - ), - ]), - }; - let mut ledger = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-api-key", - ); - ledger.phase = GenerationLedgerPhase::ReferencesPreparing; - ledger.reference_states = vec![PrivateReferenceState { - resource_id: "local-asset:reference".to_string(), - stable_reference: None, - asset_object_id: None, - upload_bucket: Some(ticket.bucket.clone()), - upload_object_key: Some(ticket.object_key.clone()), - upload_completed: false, - }]; - ledger.requested_reference_resource_ids = vec!["local-asset:reference".to_string()]; - - let persisted = serde_json::to_string_pretty(&ledger).expect("serialize private ledger"); - assert!(persisted.contains("stable-private-bucket")); - assert!(persisted.contains( - "generated-character-drafts/editor/asset-canvas-references/project/reference.png" - )); - for forbidden in [ - "uploadTicket", - "formFields", - ticket.host.as_str(), - "private-authorization", - "private-upload-policy", - "private-upload-signature", - "private-api-key", - ] { - assert!( - !persisted.contains(forbidden), - "private ledger leaked forbidden upload material: {forbidden}" - ); - } - drop(directory); - } - - #[test] - fn unknown_poll_result_keeps_the_original_operation_in_get_only_recovery() { - let project_id = "phase-five-poll-reconciliation"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五轮询对账测试"); - let mut ledger = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-key", - ); - assert!(should_poll_existing_operation(&ledger)); - ledger.phase = GenerationLedgerPhase::Running; - assert!(should_poll_existing_operation(&ledger)); - ledger.phase = GenerationLedgerPhase::ReconciliationRequired; - ledger.error_code = Some("poll-result-unknown".to_string()); - assert!(should_poll_existing_operation(&ledger)); - ledger.remote_result = Some(PrivateRemoteResult { - resource_id: "remote-resource".to_string(), - object_key: "generated/result.png".to_string(), - asset_object_id: "remote-object".to_string(), - }); - assert!(!should_poll_existing_operation(&ledger)); - drop(directory); - } - - #[test] - fn accepted_authentication_failure_stays_recoverable_with_the_original_operation() { - let project_id = "phase-five-auth-reconciliation"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五登录恢复语义测试"); - let mut ledger = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-key", - ); - let original_operation = ledger.operation_id.clone(); - let original_request = ledger.request_body_json.clone(); - let (reconciliation, code) = - classify_canvas_generation_error("authentication-required: 登录已失效"); - assert!(reconciliation); - assert_eq!(code, "authentication-required"); - let mut progress = Vec::new(); - mark_generation_error( - directory.path(), - &mut ledger, - reconciliation, - code, - &mut |event| progress.push(event), - ) - .expect("mark authentication reconciliation"); - - assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired); - assert_eq!(ledger.operation_id, original_operation); - assert_eq!(ledger.request_body_json, original_request); - assert!(should_poll_existing_operation(&ledger)); - assert_eq!(progress.len(), 1); - assert_eq!(progress[0].phase, "reconciliation-required"); - assert_eq!(progress[0].error_code.as_deref(), Some(code)); - assert!(sanitized_classified_generation_error(code, reconciliation) - .starts_with("reconciliation-required:")); - assert!(preserve_submit_reconciliation( - &GenerationLedgerPhase::Prepared, - code, - false, - )); - assert!(preserve_submit_reconciliation( - &GenerationLedgerPhase::ReconciliationRequired, - code, - false, - )); - assert!(!preserve_submit_reconciliation( - &GenerationLedgerPhase::Prepared, - "permission-denied", - false, - )); - } - - #[tokio::test] - async fn external_api_key_is_private_and_canvas_routes_are_external_only() { - let project_id = "phase-five-external-key"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五 External 凭据测试"); - let secret = "developer-api-key-must-never-be-persisted"; - let input = generation_input( - directory.path(), - project_id, - &draft, - "External 凭据不能进入私有账本", - ); - - let (mut ledger, api_base_url, api_mode) = - with_test_external_editor_credentials("http://127.0.0.1:9", secret, async { - let ledger = validate_and_prepare_ledger( - directory.path(), - &input, - "http://127.0.0.1:9", - None, - ) - .expect("prepare ledger"); - let (api_base_url, api_mode, platform_session) = - resolve_generation_api_mode().expect("resolve external mode"); - assert_eq!(platform_session, None); - (ledger, api_base_url, api_mode) - }) - .await; - assert_eq!(api_mode.bearer_token(), secret); - ledger.canvas_context = Some(PrivateCanvasContext { - project_id: "external-project".to_string(), - asset_folder_id: "external-folder".to_string(), - canvas_name: "阶段五 External 凭据测试".to_string(), - }); - let (endpoint, request_body) = - build_generation_request_snapshot(&ledger).expect("build external request"); - let request: serde_json::Value = - serde_json::from_str(&request_body).expect("parse external generation request"); - assert_eq!(endpoint, "/api/external/v1/editor/images/generations"); - assert_eq!(request["prompt"], ledger.prompt); - assert_eq!(request["aspectRatio"], ledger.aspect_ratio); - assert_eq!(request["imageSize"], ledger.image_size); - assert_eq!(request["assetKind"], ledger.asset_kind); - assert_eq!(request["assetLabel"], ledger.asset_name); - assert_eq!(request["projectId"], "external-project"); - assert_eq!(request["assetFolderId"], "external-folder"); - assert_eq!( - request["referenceImageSrcs"], - serde_json::json!(ledger.resolved_reference_ids) - ); - assert!(request.get("sourceReferenceId").is_none()); - assert!(is_external_canvas_generation_endpoint(&endpoint)); - assert!(!is_external_canvas_generation_endpoint( - "/api/editor/images/generations" - )); - assert!(!request_body.contains(secret)); - assert!(matches!( - classify_canvas_generation_initial_response( - reqwest::StatusCode::ACCEPTED, - &serde_json::json!({ - "data": { - "operationId": "external-operation", - "status": "queued" - } - }), - ) - .expect("classify external queue response"), - CanvasGenerationInitialResponse::Async(_) - )); - assert!(classify_canvas_generation_initial_response( - reqwest::StatusCode::OK, - &serde_json::json!({ - "data": { - "resourceId": "resource-1", - "objectKey": "objects/result.png" - } - }), - ) - .is_err()); - ledger.api_identity_scheme = - Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some(canvas_api_identity_fingerprint( - &api_base_url, - &api_mode, - None, - )); - write_generation_ledger(directory.path(), &mut ledger).expect("persist private ledger"); - - let persisted = fs::read_to_string( - directory - .path() - .join(generation_ledger_relative_path(&input.generation_id)), - ) - .expect("read private generation ledger"); - assert!(!persisted.contains(secret)); - assert!(!persisted.contains("accessToken")); - assert!(!persisted.contains("apiKey")); - assert!(persisted.contains("apiIdentityFingerprint")); - assert!(persisted.contains("service-origin-v1")); - } - - #[tokio::test] - async fn missing_platform_login_fails_explicitly_without_creating_an_asset() { - let project_id = "phase-five-missing-config"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); - let prompt = "配置缺失时也不能公开的正文"; - let input = generation_input(directory.path(), project_id, &draft, prompt); - let no_session = crate::platform_session::clear_test_platform_session(); - let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) - .await - .err() - .expect("missing configuration must fail"); - drop(no_session); - assert!(error.contains("陶泥儿登录态缺失")); - assert!(!error.contains(prompt)); - assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); - let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); - assert!(manifest.assets.is_empty()); - let public_draft = read_asset_canvas_draft_at( - directory.path(), - &ReadAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - ) - .expect("read failed generation draft") - .draft - .expect("failed generation draft exists"); - let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); - assert!(!public.contains(prompt)); - assert!(!public.contains(&input.idempotency_key)); - assert!(public_draft - .generations - .iter() - .any(|record| record.generation_id == input.generation_id - && record.phase == AssetCanvasGenerationStatus::Failed - && record.error_code.as_deref() == Some("configuration-missing"))); - let ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read retryable generation ledger") - .expect("missing-login ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::Failed); - assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); - assert_eq!(ledger.idempotency_key, input.idempotency_key); - assert_eq!(ledger.commit_id, input.commit_id); - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind login recovery server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("login recovery address") - ); - let signed_url = format!("{base_url}/login-recovered.png"); - let server_signed_url = signed_url.clone(); - let recovered_png = test_png(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set login recovery fixture nonblocking"); - for request_index in 0..6 { - let mut stream = accept_generation_fixture_connection( - &listener, - "login recovery fixture", - request_index, - ); - let request = read_http_request(&mut stream); - if request.starts_with("GET /api/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "login-recovery-project", - "title": "阶段五缺失配置测试", - }]}}), - ); - } else if request.starts_with("GET /api/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "login-recovery-folder", - "label": "阶段五缺失配置测试", - }]}}}), - ); - } else if request.starts_with("POST /api/editor/images/generations ") { - write_json( - &mut stream, - "202 Accepted", - serde_json::json!({"data": { - "operationId": "login-recovery-operation", - "status": "queued", - "pollAfterMs": 0, - }}), - ); - } else if request.starts_with( - "GET /api/runtime/external-generation/jobs/login-recovery-operation ", - ) { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "login-recovery-operation", - "status": "completed", - "pollAfterMs": 0, - "result": {"resource": { - "resourceId": "login-recovery-resource", - "objectKey": "generated/login-recovered.png", - "assetObjectId": "login-recovery-object", - }} - }}), - ); - } else if request.starts_with("GET /api/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": server_signed_url}}), - ); - } else if request.starts_with("GET /login-recovered.png ") { - write_png(&mut stream, &recovered_png); - } else { - panic!("unexpected login recovery request: {request}"); - } - } - }); - let _session = crate::platform_session::install_test_platform_session( - "original-login-owner", - "fresh-login-token", - &base_url, - ); - let platform_session = crate::platform_session::current_platform_session() - .expect("login recovery platform session"); - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - &platform_session.access_token, - Some(&platform_session), - "login-recovery-project", - "login-recovery-folder", - ); - generate_asset_canvas_image_at(directory.path(), &input, |_| {}) - .await - .expect("fresh login creates the first owned ledger and completes generation"); - server.join().expect("join login recovery server"); - let resumed = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read resumed login ledger") - .expect("resumed login ledger exists"); - assert_eq!(resumed.idempotency_key, input.idempotency_key); - assert_eq!(resumed.commit_id, input.commit_id); - assert_eq!( - resumed.platform_owner_user_id.as_deref(), - Some("original-login-owner") - ); - assert_eq!(resumed.phase, GenerationLedgerPhase::CandidateReady); - assert_eq!(resumed.error_code, None); - assert!(resumed.commit_result.is_none()); - let manifest = - current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest"); - assert!(manifest.assets.is_empty()); - } - - #[tokio::test] - async fn recovery_filters_mismatched_and_terminal_ledgers_before_resolving_credentials() { - let project_id = "generation-recovery-prefilter"; - let (directory, draft) = create_generation_fixture(project_id, "生成恢复预过滤测试"); - let mut terminal = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-key", - ); - terminal.phase = GenerationLedgerPhase::Failed; - terminal.error_code = Some("generation-failed".to_string()); - write_generation_ledger(directory.path(), &mut terminal).expect("write terminal ledger"); - let mut mismatched = accepted_ledger( - "other-project", - &draft, - "https://editor.example.test", - "private-key", - ); - write_generation_ledger(directory.path(), &mut mismatched) - .expect("write mismatched ledger"); - - let recovered = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - }, - |_| {}, - ) - .await - .expect("terminal and mismatched ledgers do not require credentials"); - assert!(recovered.result.resumed_generation_ids.is_empty()); - assert!(recovered.result.service_identity_confirmations.is_empty()); - assert!(recovered.events.is_empty()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn platform_session_is_revalidated_after_context_and_before_remote_submit() { - let project_id = "generation-session-submit-gate"; - let project_name = "生成提交登录态复验测试"; - let (directory, draft) = create_generation_fixture(project_id, project_name); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind session gate server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("session gate address") - ); - let (ready_sender, ready_receiver) = mpsc::channel(); - let (resume_sender, resume_receiver) = mpsc::channel(); - let (request_sender, request_receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set session gate fixture nonblocking"); - for request_index in 0..2 { - let mut stream = accept_generation_fixture_connection( - &listener, - "session gate fixture", - request_index, - ); - let request = read_http_request(&mut stream); - request_sender - .send(request.clone()) - .expect("capture session gate request"); - if request.starts_with("GET /api/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "remote-project", - "title": project_name, - }]}}), - ); - } else if request.starts_with("GET /api/editor/assets/library ") { - ready_sender.send(()).expect("signal context prepared"); - resume_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("resume context response after session switch"); - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "remote-folder", - "label": project_name, - }]}}}), - ); - } else { - panic!("unexpected session gate request: {request}"); - } - } - }); - let _session = crate::platform_session::install_test_platform_session( - "session-owner", - "session-token", - &base_url, - ); - let platform_session = crate::platform_session::current_platform_session() - .expect("session gate platform session"); - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - &platform_session.access_token, - Some(&platform_session), - "remote-project", - "remote-folder", - ); - let input = generation_input( - directory.path(), - project_id, - &draft, - "切换登录态后不得提交远端生成", - ); - let root = directory.path().to_path_buf(); - let generation_id = input.generation_id.clone(); - let generation = - tokio::spawn( - async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, - ); - ready_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("context preparation reached final await"); - crate::platform_session::clear_platform_session(2); - resume_sender.send(()).expect("release context response"); - let error = generation - .await - .expect("join session gate generation") - .err() - .expect("changed session must stop before submit"); - server.join().expect("join session gate server"); - assert_eq!(error, "陶泥儿登录已失效,请重新登录"); - let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 2); - assert!(requests.iter().all(|request| request.starts_with("GET "))); - let ledger = read_generation_ledger(directory.path(), &generation_id) - .expect("read session gate ledger") - .expect("session gate ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired); - assert!(ledger.operation_id.is_none()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn accepted_operation_is_persisted_before_session_switch_error_and_can_resume() { - let project_id = "generation-session-accepted-recovery"; - let project_name = "生成接受后切号恢复测试"; - let (directory, draft) = create_generation_fixture(project_id, project_name); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind accepted recovery server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("accepted recovery address") - ); - let signed_url = format!("{base_url}/accepted-recovered.png"); - let server_signed_url = signed_url.clone(); - let recovered_png = test_png(); - let (submit_sender, submit_receiver) = mpsc::channel(); - let (response_sender, response_receiver) = mpsc::channel(); - let (request_sender, request_receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set accepted recovery fixture nonblocking"); - for request_index in 0..6 { - let mut stream = accept_generation_fixture_connection( - &listener, - "accepted recovery fixture", - request_index, - ); - let request = read_http_request(&mut stream); - request_sender - .send(request.clone()) - .expect("capture accepted recovery request"); - if request.starts_with("GET /api/editor/projects ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "accepted-recovery-project", - "title": project_name, - }]}}), - ); - } else if request.starts_with("GET /api/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "accepted-recovery-folder", - "label": project_name, - }]}}}), - ); - } else if request.starts_with("POST /api/editor/images/generations ") { - submit_sender.send(()).expect("signal submitted generation"); - response_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("release accepted response after account switch"); - write_json( - &mut stream, - "202 Accepted", - serde_json::json!({"data": { - "operationId": "accepted-recovery-operation", - "status": "queued", - "pollAfterMs": 0, - }}), - ); - } else if request.starts_with( - "GET /api/runtime/external-generation/jobs/accepted-recovery-operation ", - ) { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": { - "operationId": "accepted-recovery-operation", - "status": "completed", - "pollAfterMs": 0, - "result": {"resource": { - "resourceId": "accepted-recovery-resource", - "objectKey": "generated/accepted-recovered.png", - "assetObjectId": "accepted-recovery-object", - }} - }}), - ); - } else if request.starts_with("GET /api/assets/read-url?") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"read": {"signedUrl": server_signed_url}}), - ); - } else if request.starts_with("GET /accepted-recovered.png ") { - write_png(&mut stream, &recovered_png); - } else { - panic!("unexpected accepted recovery request: {request}"); - } - } - }); - let _session = crate::platform_session::install_test_platform_session( - "accepted-recovery-owner", - "accepted-recovery-token", - &base_url, - ); - let platform_session = crate::platform_session::current_platform_session() - .expect("accepted recovery platform session"); - write_test_external_editor_project_binding( - directory.path(), - project_id, - &base_url, - &platform_session.access_token, - Some(&platform_session), - "accepted-recovery-project", - "accepted-recovery-folder", - ); - let input = generation_input( - directory.path(), - project_id, - &draft, - "202 返回后切号必须先保存 operationId", - ); - let root = directory.path().to_path_buf(); - let generation_id = input.generation_id.clone(); - let generation = - tokio::spawn( - async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, - ); - submit_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("generation submission reached server"); - crate::platform_session::clear_platform_session(2); - response_sender - .send(()) - .expect("release generation accepted response"); - let error = generation - .await - .expect("join accepted recovery generation") - .err() - .expect("changed account must stop before polling"); - assert!(error.contains("authentication-required")); - - let initial_requests = - std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); - assert_eq!(initial_requests.len(), 3); - assert!(initial_requests - .iter() - .all(|request| !request.starts_with("GET /api/runtime/external-generation/jobs/"))); - let accepted = read_generation_ledger(directory.path(), &generation_id) - .expect("read accepted recovery ledger") - .expect("accepted recovery ledger exists"); - assert_eq!(accepted.phase, GenerationLedgerPhase::Accepted); - assert_eq!( - accepted.operation_id.as_deref(), - Some("accepted-recovery-operation") - ); - assert_eq!(accepted.error_code, None); - - crate::platform_session::install_platform_session( - "accepted-recovery-owner", - "accepted-recovery-token", - &base_url, - 3, - ) - .expect("restore accepted recovery owner"); - let recovered = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("resume accepted operation with restored owner"); - server.join().expect("join accepted recovery server"); - assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); - let committed = read_generation_ledger( - directory.path(), - &recovered.result.resumed_generation_ids[0], - ) - .expect("read resumed accepted ledger") - .expect("resumed accepted ledger exists"); - assert_eq!(committed.phase, GenerationLedgerPhase::CandidateReady); - assert!(committed.candidate_media_ref.is_some()); - } - - #[test] - fn local_commit_session_fence_linearizes_account_switch() { - let _session = crate::platform_session::install_test_platform_session( - "commit-fence-owner", - "commit-fence-token", - "http://127.0.0.1:39321", - ); - let frozen = crate::platform_session::current_platform_session() - .expect("commit fence platform session"); - let committed = Arc::new(AtomicBool::new(false)); - let (entered_sender, entered_receiver) = mpsc::channel(); - let (release_sender, release_receiver) = mpsc::channel(); - let (switched_sender, switched_receiver) = mpsc::channel(); - - std::thread::scope(|scope| { - let committed_in_action = Arc::clone(&committed); - let frozen_in_commit = frozen.clone(); - let commit = scope.spawn(move || { - with_frozen_generation_platform_session_commit(Some(&frozen_in_commit), || { - entered_sender - .send(()) - .expect("signal commit fence entered"); - release_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("release linearized local commit"); - committed_in_action.store(true, Ordering::SeqCst); - Ok(()) - }) - }); - entered_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("local commit entered session fence"); - let switch = scope.spawn(move || { - crate::platform_session::clear_platform_session(2); - switched_sender - .send(()) - .expect("signal account switch completed"); - }); - assert!(switched_receiver - .recv_timeout(Duration::from_millis(100)) - .is_err()); - assert!(!committed.load(Ordering::SeqCst)); - release_sender.send(()).expect("release local commit"); - commit - .join() - .expect("join local commit") - .expect("linearized local commit succeeds"); - switch.join().expect("join account switch"); - switched_receiver - .recv_timeout(Duration::from_secs(5)) - .expect("account switch completes after local commit"); - }); - assert!(committed.load(Ordering::SeqCst)); - - let called_after_switch = AtomicBool::new(false); - let error = with_frozen_generation_platform_session_commit(Some(&frozen), || { - called_after_switch.store(true, Ordering::SeqCst); - Ok(()) - }) - .expect_err("stale session cannot enter local commit"); - assert!(error.contains("authentication-required")); - assert!(!called_after_switch.load(Ordering::SeqCst)); - } - - #[test] - fn cancelled_draft_rejects_public_generation_projection_and_keeps_private_evidence() { - let project_id = "cancelled-generation-projection"; - let (directory, draft) = create_generation_fixture(project_id, "取消生成投影测试"); - let mut ledger = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-key", - ); - write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); - let cancelled = discard_asset_canvas_draft_at( - directory.path(), - &DiscardAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - }, - ) - .expect("cancel generation draft"); - assert_eq!(cancelled.status, "cancelled"); - - let error = mark_generation_error( - directory.path(), - &mut ledger, - true, - "poll-result-unknown", - &mut |_| {}, - ) - .expect_err("cancelled draft must reject public projection"); - assert!(error.contains("generation-cancelled")); - let persisted = read_generation_ledger(directory.path(), &ledger.generation_id) - .expect("read private cancelled ledger") - .expect("private cancelled ledger exists"); - assert_eq!(persisted.phase, GenerationLedgerPhase::Accepted); - assert_eq!(persisted.error_code, None); - assert_eq!(persisted.operation_id, ledger.operation_id); - let public = read_asset_canvas_draft_at( - directory.path(), - &ReadAssetCanvasDraftInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, - }, - ) - .expect("read cancelled draft") - .draft - .expect("cancelled draft exists"); - assert_eq!(public.status, AssetCanvasDraftStatus::Cancelled); - assert!(public.generations.is_empty()); - } - - #[test] - fn legacy_failed_credential_ledgers_migrate_by_remote_side_effect_boundary_only() { - let project_id = "legacy-credential-ledger-migration"; - let (directory, draft) = create_generation_fixture(project_id, "旧凭据账本恢复测试"); - for (has_request, has_operation, expected_phase) in [ - (false, false, GenerationLedgerPhase::ContextPreparing), - (true, false, GenerationLedgerPhase::Prepared), - (true, true, GenerationLedgerPhase::ReconciliationRequired), - ] { - let mut ledger = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-key", - ); - ledger.phase = GenerationLedgerPhase::Failed; - ledger.error_code = Some("authentication-required".to_string()); - if !has_request { - ledger.request_body_json = None; - ledger.request_body_sha256 = None; - ledger.endpoint = None; - } - if !has_operation { - ledger.operation_id = None; - ledger.poll_after_ms = None; - } - write_generation_ledger(directory.path(), &mut ledger) - .expect("write legacy failed credential ledger"); - assert!( - migrate_retryable_credential_failure(directory.path(), &mut ledger) - .expect("migrate legacy failed credential ledger") - ); - assert_eq!(ledger.phase, expected_phase); - assert_eq!( - ledger.error_code.as_deref(), - Some("authentication-required") - ); - } - - let mut business_failure = accepted_ledger( - project_id, - &draft, - "https://editor.example.test", - "private-key", - ); - business_failure.phase = GenerationLedgerPhase::Failed; - business_failure.error_code = Some("generation-rejected".to_string()); - write_generation_ledger(directory.path(), &mut business_failure) - .expect("write definitive business failure"); - assert!( - !migrate_retryable_credential_failure(directory.path(), &mut business_failure) - .expect("leave definitive business failure terminal") - ); - assert_eq!(business_failure.phase, GenerationLedgerPhase::Failed); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs deleted file mode 100644 index d24f2c184..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ /dev/null @@ -1,2356 +0,0 @@ -use super::*; -use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; -use std::io::Cursor; -use std::sync::{Arc, Barrier}; - -const PROJECT_ID: &str = "asset-canvas-test-project"; - -struct Fixture { - directory: tempfile::TempDir, - draft: AssetCanvasDraft, - png: Vec, -} - -impl Fixture { - fn root(&self) -> &Path { - self.directory.path() - } -} - -fn png_bytes(color: [u8; 4]) -> Vec { - let image = RgbaImage::from_pixel(4, 3, Rgba(color)); - let mut output = Cursor::new(Vec::new()); - DynamicImage::ImageRgba8(image) - .write_to(&mut output, ImageFormat::Png) - .expect("encode PNG fixture"); - output.into_inner() -} - -fn project_path(root: &Path) -> String { - root.to_string_lossy().into_owned() -} - -fn create_input( - root: &Path, - draft_id: &str, - intent: AssetCanvasIntent, - source_asset_id: Option, -) -> CreateAssetCanvasDraftInput { - CreateAssetCanvasDraftInput { - project_path: project_path(root), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft_id.to_string(), - intent, - source_asset_id, - } -} - -fn initialize_fixture() -> Fixture { - let directory = tempfile::tempdir().expect("create asset canvas fixture"); - init_local_game_project_at(directory.path(), PROJECT_ID, "素材画布测试项目") - .expect("initialize project"); - let draft_id = Uuid::new_v4().to_string(); - let draft = create_asset_canvas_draft_at( - directory.path(), - &create_input(directory.path(), &draft_id, AssetCanvasIntent::Create, None), - ) - .expect("create draft") - .draft; - Fixture { - directory, - draft, - png: png_bytes([33, 99, 180, 255]), - } -} - -fn add_imported_layer(fixture: &Fixture, draft: &AssetCanvasDraft) -> AssetCanvasDraft { - let stored = store_asset_canvas_media_at( - fixture.root(), - &StoreAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - ) - .expect("store imported media"); - let media_ref = stored.media_ref.expect("stored media ref"); - let mut canvas = draft.canvas.clone(); - let layer_id = Uuid::new_v4().to_string(); - canvas.layers.push(AssetCanvasLayer { - layer_id: layer_id.clone(), - resource_id: format!("draft-media:{layer_id}"), - title: "导入图片".to_string(), - media_ref, - x: 20.0, - y: 30.0, - width: 400.0, - height: 300.0, - original_width: 4.0, - original_height: 3.0, - z_index: 0, - group_id: None, - hidden: false, - locked: false, - flip_x: false, - flip_y: false, - }); - canvas.selected_layer_ids = vec![layer_id.clone()]; - canvas.primary_selected_layer_id = Some(layer_id); - update_asset_canvas_draft_at( - fixture.root(), - &UpdateAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: Vec::new(), - }, - ) - .expect("persist imported layer") - .draft -} - -fn initialize_refine_fixture_with_resource_id(resource_id: Option<&str>) -> Fixture { - let directory = tempfile::tempdir().expect("create refine fixture"); - init_local_game_project_at(directory.path(), PROJECT_ID, "候选提交测试项目") - .expect("initialize refine project"); - let source_bytes = png_bytes([200, 20, 40, 255]); - fs::write(directory.path().join("assets/source.png"), source_bytes) - .expect("write refine source asset"); - let manifest_path = directory.path().join(".agent/manifest.json"); - let mut manifest = read_manifest(&manifest_path).expect("read refine manifest"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "source-asset".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: resource_id.map(str::to_string), - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }); - write_manifest(&manifest_path, &manifest).expect("register refine source asset"); - let draft = create_asset_canvas_draft_at( - directory.path(), - &create_input( - directory.path(), - &Uuid::new_v4().to_string(), - AssetCanvasIntent::Refine, - Some("source-asset".to_string()), - ), - ) - .expect("create refine draft") - .draft; - Fixture { - directory, - draft, - png: png_bytes([20, 200, 40, 255]), - } -} - -fn initialize_refine_fixture() -> Fixture { - initialize_refine_fixture_with_resource_id(None) -} - -fn force_refine_transaction_unresolved(fixture: &Fixture, commit_id: &str) { - let mut journal = read_asset_canvas_journal(fixture.root(), commit_id) - .expect("read transaction to mark unresolved") - .expect("transaction journal retained"); - let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id) - .expect("read transaction ledger") - .expect("transaction ledger retained"); - journal.stage = AssetCanvasTransactionStage::ReconciliationRequired; - ledger.status = AssetCanvasLedgerStatus::ReconciliationRequired; - write_asset_canvas_journal(fixture.root(), &journal).expect("mark journal unresolved"); - write_asset_canvas_ledger(fixture.root(), &ledger).expect("mark ledger unresolved"); -} - -fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) { - let fixture = initialize_refine_fixture(); - let (draft, first_layer_id, first_media_sha256) = add_candidate_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let mut first_input = commit_input( - &fixture, - &draft, - &staged, - "f0000000-0000-4000-8000-000000000001".to_string(), - Uuid::new_v4().to_string(), - ); - first_input.source_layer_id = Some(first_layer_id.clone()); - first_input.media_sha256 = Some(first_media_sha256); - let first_commit_id = first_input.commit_id.clone(); - commit_asset_canvas_at(fixture.root(), &first_input).expect("commit first final image"); - - let draft_after_first = - read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) - .expect("read draft after first commit") - .expect("draft retained after first commit"); - let (draft_before_second, second_layer_id, second_media_sha256) = - add_candidate_layer(&fixture, &draft_after_first); - let staged = stage_image(&fixture, &draft_before_second); - let mut second_input = commit_input( - &fixture, - &draft_before_second, - &staged, - "10000000-0000-4000-8000-000000000002".to_string(), - Uuid::new_v4().to_string(), - ); - second_input.source_layer_id = Some(second_layer_id); - second_input.media_sha256 = Some(second_media_sha256); - let second_commit_id = second_input.commit_id.clone(); - commit_asset_canvas_at(fixture.root(), &second_input).expect("commit second final image"); - force_refine_transaction_unresolved(&fixture, &first_commit_id); - (fixture, first_commit_id, second_commit_id) -} - -fn add_candidate_layer( - fixture: &Fixture, - draft: &AssetCanvasDraft, -) -> (AssetCanvasDraft, String, String) { - let stored = store_asset_canvas_media_at( - fixture.root(), - &StoreAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - ) - .expect("store candidate media"); - let media_ref = stored.media_ref.expect("candidate media ref"); - let media_sha256 = match &media_ref { - AssetCanvasMediaRef::DraftMedia { sha256, .. } => sha256.clone(), - AssetCanvasMediaRef::ProjectAsset { .. } => panic!("candidate must use draft media"), - }; - let layer_id = Uuid::new_v4().to_string(); - let mut canvas = draft.canvas.clone(); - let z_index = canvas - .layers - .iter() - .map(|layer| layer.z_index) - .max() - .unwrap_or(0) - + 1; - canvas.layers.push(AssetCanvasLayer { - layer_id: layer_id.clone(), - resource_id: format!("draft-media:{layer_id}"), - title: "候选图片".to_string(), - media_ref, - x: 7000.0, - y: 6000.0, - width: 400.0, - height: 300.0, - original_width: 4.0, - original_height: 3.0, - z_index, - group_id: None, - hidden: false, - locked: false, - flip_x: false, - flip_y: false, - }); - canvas.selected_layer_ids = vec![layer_id.clone()]; - canvas.primary_selected_layer_id = Some(layer_id.clone()); - let mut updated = update_asset_canvas_draft_at( - fixture.root(), - &UpdateAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: draft.generations.clone(), - }, - ) - .expect("persist candidate layer") - .draft; - let now = asset_canvas_now(); - updated.generations.push(AssetCanvasGenerationRecord { - generation_id: Uuid::new_v4().to_string(), - intent_id: Uuid::new_v4().to_string(), - phase: AssetCanvasGenerationStatus::CandidateReady, - reference_resource_ids: Vec::new(), - output_asset_id: None, - source_layer_id: draft.canvas.primary_selected_layer_id.clone(), - placeholder: Some(AssetCanvasGenerationPlaceholder { - x: 7000.0, - y: 6000.0, - width: 400.0, - height: 300.0, - }), - error_code: None, - created_at: now, - updated_at: now, - idempotency_key: None, - status: None, - prompt: None, - operation_id: None, - output_media_ids: Vec::new(), - }); - write_asset_canvas_draft_locked(fixture.root(), &updated) - .expect("persist authoritative candidate generation"); - (updated, layer_id, media_sha256) -} - -fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasImageResult { - stage_asset_canvas_image_at( - fixture.root(), - &StageAssetCanvasImageInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - ) - .expect("stage image") -} - -#[test] -fn stable_staging_token_repairs_image_first_and_metadata_first_partial_installs() { - for image_first in [true, false] { - let fixture = initialize_fixture(); - let token = Uuid::new_v4().to_string(); - let staging_directory = fixture - .root() - .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")); - fs::create_dir_all(&staging_directory).expect("create partial staging directory"); - let expires_at = asset_canvas_now() - .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) - .min(ASSET_CANVAS_MAX_SAFE_INTEGER); - if image_first { - fs::write(staging_directory.join("image.png"), &fixture.png) - .expect("simulate image-first crash"); - } else { - let metadata = AssetCanvasStagedImage { - schema_version: "game-creator-asset-canvas-staging.v1".to_string(), - project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - draft_revision: fixture.draft.revision, - staged_image_token: token.clone(), - media_type: "image/png".to_string(), - sha256: asset_canvas_sha256(&fixture.png), - byte_length: fixture.png.len() as u64, - pixel_width: 4, - pixel_height: 3, - expires_at, - }; - write_agent_runtime_json_sidecar_with_max_bytes( - fixture.root(), - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), - "素材画布 staging 元数据", - &metadata, - 16 * 1024, - ) - .expect("simulate metadata-first crash"); - } - - let repaired = stage_asset_canvas_image_with_token_at( - fixture.root(), - &StageAssetCanvasImageInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - Some(&token), - ) - .expect("repair partial stable staging install"); - assert_eq!(repaired.status, "staged"); - assert_eq!(repaired.staged_image_token.as_deref(), Some(token.as_str())); - let (metadata, bytes) = - read_staged_image_locked(fixture.root(), &token).expect("read repaired staging"); - assert_eq!(metadata.project_id, PROJECT_ID); - assert_eq!(metadata.draft_id, fixture.draft.draft_id); - assert_eq!(bytes, fixture.png); - - let replay = stage_asset_canvas_image_with_token_at( - fixture.root(), - &StageAssetCanvasImageInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - Some(&token), - ) - .expect("replay repaired stable staging install"); - assert_eq!(replay.staged_image_token, repaired.staged_image_token); - } -} - -#[test] -fn stable_staging_token_rejects_conflicting_partial_install() { - let fixture = initialize_fixture(); - let token = Uuid::new_v4().to_string(); - let staging_directory = fixture - .root() - .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")); - fs::create_dir_all(&staging_directory).expect("create conflicting staging directory"); - fs::write(staging_directory.join("image.png"), &fixture.png) - .expect("write conflicting image-first residue"); - let different_png = png_bytes([220, 31, 54, 255]); - let error = stage_asset_canvas_image_with_token_at( - fixture.root(), - &StageAssetCanvasImageInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - media_type: "image/png".to_string(), - bytes: different_png, - }, - Some(&token), - ) - .expect_err("conflicting half-installed stable token must fail closed"); - assert!(error.contains("摘要不匹配") || error.contains("绑定到不同图片")); - assert!(read_staged_image_metadata_locked(fixture.root(), &token) - .expect("read missing conflicting metadata") - .is_none()); - assert_eq!( - fs::read(staging_directory.join("image.png")).expect("read preserved residue"), - fixture.png - ); -} - -#[test] -fn cancelled_draft_rejects_staging_before_writing_token() { - let fixture = initialize_fixture(); - discard_asset_canvas_draft_at( - fixture.root(), - &DiscardAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - }, - ) - .expect("cancel staging fixture draft"); - let token = Uuid::new_v4().to_string(); - let error = stage_asset_canvas_image_with_token_at( - fixture.root(), - &StageAssetCanvasImageInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - Some(&token), - ) - .expect_err("cancelled draft must reject stable staging"); - assert!(error.contains("草稿已取消或提交")); - assert!(!fixture - .root() - .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")) - .exists()); -} - -fn commit_input( - fixture: &Fixture, - draft: &AssetCanvasDraft, - staged: &StageAssetCanvasImageResult, - commit_id: String, - idempotency_key: String, -) -> CommitAssetCanvasInput { - CommitAssetCanvasInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - expected_revision: read_game_creator_agent_runtime_project_revision(fixture.root()) - .expect("read revision") - .revision, - expected_draft_revision: draft.revision, - draft_id: draft.draft_id.clone(), - commit_id, - idempotency_key, - intent: draft.intent.clone(), - source_asset_id: draft.source_asset_id.clone(), - staged_image_token: staged.staged_image_token.clone().expect("staging token"), - source_layer_id: None, - media_sha256: None, - name: "测试素材".to_string(), - asset_kind: "illustration".to_string(), - reference_resource_ids: Vec::new(), - generation_provenance: None, - } -} - -#[test] -fn create_import_edit_commit_installs_file_and_manifest_and_replays_idempotently() { - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let mut input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - let first = commit_asset_canvas_at(fixture.root(), &input).expect("commit asset"); - let (asset, event_id) = match first.result { - CommitAssetCanvasResult::Committed { - asset, - event_id, - project_revision, - draft_revision, - .. - } => { - assert_eq!(project_revision, 1); - assert_eq!(draft_revision, draft.revision + 1); - (asset, event_id) - } - other => panic!("unexpected commit result: {other:?}"), - }; - assert_eq!( - fs::read(fixture.root().join(&asset.local_path)).expect("read committed image"), - fixture.png - ); - let manifest = current_asset_canvas_manifest(fixture.root()).expect("read committed manifest"); - assert!(manifest.assets.iter().any(|entry| entry == &asset)); - let replay = commit_asset_canvas_at(fixture.root(), &input).expect("replay commit"); - match replay.result { - CommitAssetCanvasResult::AlreadyCommitted { - event_id: replay_event_id, - asset: replay_asset, - .. - } => { - assert_eq!(replay_event_id, event_id); - assert_eq!(replay_asset, asset); - } - other => panic!("unexpected replay result: {other:?}"), - } - input.name = "不同素材名".to_string(); - assert!(commit_asset_canvas_at(fixture.root(), &input) - .expect_err("reject idempotency identity reuse") - .contains("已绑定到不同提交请求")); -} - -#[test] -fn event_emit_failure_does_not_rollback_committed_business_state() { - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit before emit"); - let event = execution.event.as_ref().expect("committed event"); - let manifest_before_emit = current_asset_canvas_manifest(fixture.root()).expect("manifest"); - let revision_before_emit = read_game_creator_agent_runtime_project_revision(fixture.root()) - .expect("revision") - .revision; - assert!(!publish_asset_canvas_event_after_commit_at( - fixture.root(), - event, - |_| Err("injected emit failure".to_string()), - )); - assert_eq!( - current_asset_canvas_manifest(fixture.root()).expect("manifest after failed emit"), - manifest_before_emit - ); - assert_eq!( - read_game_creator_agent_runtime_project_revision(fixture.root()) - .expect("revision after failed emit") - .revision, - revision_before_emit - ); - let ledger = read_asset_canvas_ledger(fixture.root(), &input.commit_id) - .expect("read ledger") - .expect("committed ledger"); - assert_eq!(ledger.status, AssetCanvasLedgerStatus::Committed); - assert_eq!(ledger.event_delivery, AssetCanvasEventDelivery::Attempted); -} - -#[test] -fn refine_preserves_source_and_records_non_destructive_lineage() { - let directory = tempfile::tempdir().expect("create refine fixture"); - init_local_game_project_at(directory.path(), PROJECT_ID, "精修测试项目") - .expect("initialize project"); - let source_bytes = png_bytes([200, 20, 40, 255]); - fs::write(directory.path().join("assets/source.png"), &source_bytes) - .expect("write source asset"); - let manifest_path = directory.path().join(".agent/manifest.json"); - let mut manifest = read_manifest(&manifest_path).expect("read manifest"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "source-asset".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }); - write_manifest(&manifest_path, &manifest).expect("register source asset"); - let draft = create_asset_canvas_draft_at( - directory.path(), - &create_input( - directory.path(), - &Uuid::new_v4().to_string(), - AssetCanvasIntent::Refine, - Some("source-asset".to_string()), - ), - ) - .expect("create refine draft") - .draft; - let source_layer = draft.canvas.layers.first().expect("refine source layer"); - match &source_layer.media_ref { - AssetCanvasMediaRef::DraftMedia { - media_id, - media_type, - sha256, - .. - } => { - let source_path = directory.path().join(format!( - ".agent/workbench/asset-canvas/media/{}/{}.{}", - draft.draft_id, - media_id, - media_extension(media_type).expect("source extension") - )); - assert_eq!( - fs::read(source_path).expect("read source snapshot"), - source_bytes - ); - assert_eq!(sha256, &asset_canvas_sha256(&source_bytes)); - } - AssetCanvasMediaRef::ProjectAsset { .. } => { - panic!("refine source must be immutable snapshot") - } - } - assert_eq!(source_layer.resource_id, "local-asset:source-asset"); - let fixture = Fixture { - directory, - draft: draft.clone(), - png: png_bytes([20, 200, 40, 255]), - }; - let staged = stage_image(&fixture, &draft); - let input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - fs::create_dir_all(fixture.root().join("game")).expect("create game directory"); - fs::write( - fixture.root().join("game/game.js"), - "const backgroundImage = '../assets/source.png';\n", - ) - .expect("write stable game resource reference"); - let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit refine asset"); - assert!(fixture.root().join("game/game.js").is_file()); - let committed_asset = match execution.result { - CommitAssetCanvasResult::Committed { asset, .. } => asset, - other => panic!("unexpected refine result: {other:?}"), - }; - let manifest = current_asset_canvas_manifest(fixture.root()).expect("read refine manifest"); - assert_eq!(manifest.assets.len(), 1); - let source = manifest - .assets - .iter() - .find(|asset| asset.id == "source-asset") - .expect("source retained"); - assert_eq!(committed_asset.id, source.id); - assert_eq!(committed_asset.local_path, source.local_path); - assert_ne!(committed_asset.local_path, "assets/source.png"); - assert_eq!(source.media_type, "image/png"); - assert_eq!( - fs::read(fixture.root().join(&source.local_path)).expect("read committed source"), - fixture.png - ); - assert_eq!( - fs::read(fixture.root().join("assets/source.png")).expect("read stable runtime entry"), - fixture.png - ); - assert_eq!( - fs::read_to_string(fixture.root().join("game/game.js")) - .expect("read stable game resource reference"), - "const backgroundImage = '../assets/source.png';\n" - ); - let journal = read_asset_canvas_journal(fixture.root(), &input.commit_id) - .expect("read runtime entry journal") - .expect("runtime entry journal retained"); - assert_eq!( - journal.runtime_entry_relative_path.as_deref(), - Some("assets/source.png") - ); - assert!(journal.runtime_entry_refreshed); - - fs::write( - fixture.root().join("assets/source.png"), - png_bytes([90, 120, 150, 255]), - ) - .expect("corrupt stable runtime entry"); - recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("repair stable runtime entry"); - assert_eq!( - fs::read(fixture.root().join("assets/source.png")) - .expect("read repaired stable runtime entry"), - fixture.png - ); - assert_eq!( - source.source.resource_id.as_deref(), - Some("local-asset:source-asset") - ); -} - -#[test] -fn legacy_refine_transaction_preserves_runtime_entry_for_next_commit() { - let directory = tempfile::tempdir().expect("create legacy runtime entry fixture"); - init_local_game_project_at(directory.path(), PROJECT_ID, "旧事务精修项目") - .expect("initialize project"); - fs::write( - directory.path().join("assets/source.png"), - png_bytes([190, 40, 90, 255]), - ) - .expect("write source asset"); - let manifest_path = directory.path().join(".agent/manifest.json"); - let mut manifest = read_manifest(&manifest_path).expect("read manifest"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "source-asset".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }); - write_manifest(&manifest_path, &manifest).expect("register source asset"); - let draft = create_asset_canvas_draft_at( - directory.path(), - &create_input( - directory.path(), - &Uuid::new_v4().to_string(), - AssetCanvasIntent::Refine, - Some("source-asset".to_string()), - ), - ) - .expect("create refine draft") - .draft; - let fixture = Fixture { - directory, - draft: draft.clone(), - png: png_bytes([30, 90, 210, 255]), - }; - let staged = stage_image(&fixture, &draft); - let first_input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - commit_asset_canvas_at(fixture.root(), &first_input) - .expect("commit legacy runtime entry image"); - - let mut legacy_journal = read_asset_canvas_journal(fixture.root(), &first_input.commit_id) - .expect("read legacy journal") - .expect("legacy journal retained"); - legacy_journal.runtime_entry_relative_path = None; - legacy_journal.runtime_entry_refreshed = false; - write_asset_canvas_journal(fixture.root(), &legacy_journal) - .expect("persist legacy journal shape"); - fs::write( - fixture.root().join("assets/source.png"), - png_bytes([1, 2, 3, 255]), - ) - .expect("corrupt legacy runtime entry"); - - let draft_after_first = - read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) - .expect("read draft after legacy commit") - .expect("draft retained after legacy commit"); - let second_png = png_bytes([210, 180, 40, 255]); - let staged_second = stage_asset_canvas_image_at( - fixture.root(), - &StageAssetCanvasImageInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft_after_first.revision, - media_type: "image/png".to_string(), - bytes: second_png.clone(), - }, - ) - .expect("stage second legacy image"); - let second_input = commit_input( - &fixture, - &draft_after_first, - &staged_second, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - commit_asset_canvas_at(fixture.root(), &second_input) - .expect("commit after legacy runtime entry migration"); - - assert_eq!( - fs::read(fixture.root().join("assets/source.png")).expect("read migrated runtime entry"), - second_png - ); - let second_journal = read_asset_canvas_journal(fixture.root(), &second_input.commit_id) - .expect("read migrated journal") - .expect("migrated journal retained"); - assert_eq!( - second_journal.runtime_entry_relative_path.as_deref(), - Some("assets/source.png") - ); - assert!(second_journal.runtime_entry_refreshed); -} - -#[test] -fn candidate_commit_freezes_layer_and_media_identity_and_keeps_draft_editable() { - let fixture = initialize_refine_fixture(); - let (draft, candidate_layer_id, media_sha256) = add_candidate_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let mut mismatched = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - mismatched.source_layer_id = Some(candidate_layer_id.clone()); - mismatched.media_sha256 = Some("0".repeat(64)); - assert!(commit_asset_canvas_at(fixture.root(), &mismatched) - .expect_err("reject mismatched candidate digest") - .contains("候选媒体摘要")); - - let commit_id = Uuid::new_v4().to_string(); - let execution = commit_asset_canvas_candidate_at( - fixture.root(), - &CommitAssetCanvasCandidateInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - expected_revision: 0, - expected_draft_revision: draft.revision, - draft_id: draft.draft_id.clone(), - commit_id: commit_id.clone(), - idempotency_key: Uuid::new_v4().to_string(), - source_asset_id: draft.source_asset_id.clone(), - source_layer_id: candidate_layer_id.clone(), - name: "候选最终图".to_string(), - asset_kind: "illustration".to_string(), - reference_resource_ids: Vec::new(), - }, - ) - .expect("commit selected candidate"); - let committed_asset = match execution.result { - CommitAssetCanvasResult::Committed { asset, .. } => asset, - other => panic!("unexpected candidate commit result: {other:?}"), - }; - assert_eq!(committed_asset.id, "source-asset"); - - let persisted = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) - .expect("read candidate draft") - .expect("candidate draft retained"); - assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); - assert!(persisted.pending_commit.is_none()); - assert_eq!(persisted.canvas.layers, draft.canvas.layers); - assert_eq!(persisted.generations, draft.generations); - let last_commit = persisted.last_commit.expect("candidate last commit"); - assert_eq!(last_commit.commit_id, commit_id); - assert_eq!(last_commit.asset_id, "source-asset"); - assert_eq!(last_commit.source_layer_id, candidate_layer_id); - assert_eq!(last_commit.media_sha256, media_sha256); - let source_layer = persisted - .canvas - .layers - .iter() - .find(|layer| layer.resource_id == "local-asset:source-asset") - .expect("immutable refine source layer retained"); - let source_media = read_asset_canvas_media_at( - fixture.root(), - &ReadAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: persisted.draft_id.clone(), - media_ref: source_layer.media_ref.clone(), - }, - ) - .expect("read immutable refine source layer"); - assert_eq!(source_media.bytes, png_bytes([200, 20, 40, 255])); - let candidate_layer = persisted - .canvas - .layers - .iter() - .find(|layer| layer.layer_id == candidate_layer_id) - .expect("candidate layer retained"); - let candidate_media = read_asset_canvas_media_at( - fixture.root(), - &ReadAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: persisted.draft_id.clone(), - media_ref: candidate_layer.media_ref.clone(), - }, - ) - .expect("read selected final candidate"); - assert_eq!(candidate_media.bytes, fixture.png); - - let serialized = fs::read_to_string( - fixture - .root() - .join(asset_canvas_draft_relative_path(&draft.draft_id)), - ) - .expect("read serialized candidate draft"); - let serialized: serde_json::Value = - serde_json::from_str(&serialized).expect("parse candidate draft"); - assert_eq!(serialized["status"], "editing"); - assert_eq!( - serialized["lastCommit"]["sourceLayerId"], - last_commit.source_layer_id - ); - assert_eq!( - serialized["lastCommit"]["mediaSha256"], - last_commit.media_sha256 - ); -} - -#[test] -fn candidate_commit_preserves_registered_refine_source_identity() { - let registered_resource_id = "editor-resource-registered-refine-source"; - let fixture = initialize_refine_fixture_with_resource_id(Some(registered_resource_id)); - let (draft, candidate_layer_id, _) = add_candidate_layer(&fixture, &fixture.draft); - - let execution = commit_asset_canvas_candidate_at( - fixture.root(), - &CommitAssetCanvasCandidateInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - expected_revision: 0, - expected_draft_revision: draft.revision, - draft_id: draft.draft_id.clone(), - commit_id: Uuid::new_v4().to_string(), - idempotency_key: Uuid::new_v4().to_string(), - source_asset_id: draft.source_asset_id.clone(), - source_layer_id: candidate_layer_id, - name: "已登记来源候选最终图".to_string(), - asset_kind: "illustration".to_string(), - reference_resource_ids: Vec::new(), - }, - ) - .expect("commit candidate with registered source identity"); - let asset = match execution.result { - CommitAssetCanvasResult::Committed { asset, .. } => asset, - other => panic!("unexpected registered source commit result: {other:?}"), - }; - assert_eq!( - asset.source.resource_id.as_deref(), - Some(registered_resource_id) - ); - assert_eq!(asset.id, "source-asset"); -} - -#[test] -fn invalid_candidate_name_fails_before_staging_side_effects() { - let fixture = initialize_refine_fixture(); - let (draft, candidate_layer_id, _) = add_candidate_layer(&fixture, &fixture.draft); - let error = commit_asset_canvas_candidate_at( - fixture.root(), - &CommitAssetCanvasCandidateInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - expected_revision: 0, - expected_draft_revision: draft.revision, - draft_id: draft.draft_id.clone(), - commit_id: Uuid::new_v4().to_string(), - idempotency_key: Uuid::new_v4().to_string(), - source_asset_id: draft.source_asset_id.clone(), - source_layer_id: candidate_layer_id, - name: "长".repeat(81), - asset_kind: "illustration".to_string(), - reference_resource_ids: Vec::new(), - }, - ) - .expect_err("reject invalid candidate name before staging"); - assert_eq!(error, "素材名称无效"); - let staging = fixture.root().join(".agent/workbench/asset-canvas/staging"); - match fs::read_dir(&staging) { - Ok(entries) => assert_eq!(entries.count(), 0), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => panic!("read candidate staging directory failed: {error}"), - } -} - -#[test] -fn candidate_commit_recovery_accepts_registered_refine_source_after_revision_install() { - let registered_resource_id = "editor-resource-registered-recovery-source"; - let fixture = initialize_refine_fixture_with_resource_id(Some(registered_resource_id)); - let (draft, candidate_layer_id, media_sha256) = add_candidate_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let mut input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - input.source_layer_id = Some(candidate_layer_id.clone()); - input.media_sha256 = Some(media_sha256.clone()); - - assert!(commit_asset_canvas_at_internal( - fixture.root(), - &input, - Some(AssetCanvasCommitFaultStage::RevisionInstalled), - ) - .expect_err("interrupt registered refine commit after revision install") - .starts_with("fault-injected:")); - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover registered refine candidate commit"); - assert!(recovered.result.outcomes.iter().any(|outcome| { - outcome.commit_id == input.commit_id - && matches!( - outcome.status, - RecoverAssetCanvasOutcomeStatus::Committed - | RecoverAssetCanvasOutcomeStatus::AlreadyCommitted - ) - })); - let manifest = read_manifest(&fixture.root().join(".agent/manifest.json")) - .expect("read recovered registered refine manifest"); - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == "source-asset") - .expect("recovered source asset"); - assert_eq!( - asset.source.resource_id.as_deref(), - Some(registered_resource_id) - ); - let persisted = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) - .expect("read recovered registered refine draft") - .expect("recovered registered refine draft retained"); - assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); - let last_commit = persisted - .last_commit - .expect("recovered registered last commit"); - assert_eq!(last_commit.source_layer_id, candidate_layer_id); - assert_eq!(last_commit.media_sha256, media_sha256); -} - -#[test] -fn candidate_commit_recovery_restores_editing_and_last_commit_identity() { - let fixture = initialize_refine_fixture(); - let (draft, candidate_layer_id, media_sha256) = add_candidate_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let mut input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - input.source_layer_id = Some(candidate_layer_id.clone()); - input.media_sha256 = Some(media_sha256.clone()); - - assert!(commit_asset_canvas_at_internal( - fixture.root(), - &input, - Some(AssetCanvasCommitFaultStage::LedgerCommitted), - ) - .expect_err("interrupt candidate commit after ledger") - .starts_with("fault-injected:")); - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover candidate commit"); - assert!(recovered.result.outcomes.iter().any(|outcome| { - outcome.commit_id == input.commit_id - && matches!( - outcome.status, - RecoverAssetCanvasOutcomeStatus::Committed - | RecoverAssetCanvasOutcomeStatus::AlreadyCommitted - ) - })); - let persisted = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) - .expect("read recovered candidate draft") - .expect("recovered candidate draft retained"); - assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); - assert_eq!(persisted.canvas.layers, draft.canvas.layers); - assert_eq!(persisted.generations, draft.generations); - let last_commit = persisted.last_commit.expect("recovered last commit"); - assert_eq!(last_commit.source_layer_id, candidate_layer_id); - assert_eq!(last_commit.media_sha256, media_sha256); -} - -#[test] -fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit() { - let (fixture, first_commit_id, second_commit_id) = - initialize_refine_fixture_with_later_commit(); - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover superseded refine transaction"); - let first_outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == first_commit_id) - .expect("first recovery outcome"); - let second_outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == second_commit_id) - .expect("second recovery outcome"); - assert_eq!( - first_outcome.status, - RecoverAssetCanvasOutcomeStatus::Superseded - ); - assert_eq!( - second_outcome.status, - RecoverAssetCanvasOutcomeStatus::AlreadyCommitted - ); - - let first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) - .expect("read superseded journal") - .expect("superseded journal retained"); - let first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id) - .expect("read superseded ledger") - .expect("superseded ledger retained"); - assert_eq!(first_journal.stage, AssetCanvasTransactionStage::Superseded); - assert_eq!(first_ledger.status, AssetCanvasLedgerStatus::Superseded); - assert!(fixture - .root() - .join(&first_journal.final_image_relative_path) - .is_file()); - - let second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id) - .expect("read current journal") - .expect("current journal retained"); - let manifest = current_asset_canvas_manifest(fixture.root()).expect("read current manifest"); - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == "source-asset") - .expect("current refine source asset"); - assert_eq!(asset.local_path, second_journal.final_image_relative_path); - let persisted = - read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) - .expect("read current draft") - .expect("current draft retained"); - assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing); - assert_eq!( - persisted - .last_commit - .as_ref() - .map(|last| last.commit_id.as_str()), - Some(second_commit_id.as_str()) - ); - - let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("repeat superseded recovery"); - assert!(repeated.result.outcomes.iter().any(|outcome| { - outcome.commit_id == first_commit_id - && outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded - })); - assert!(!repeated - .result - .outcomes - .iter() - .any(|outcome| outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired)); - - let (draft_before_third, third_layer_id, third_media_sha256) = - add_candidate_layer(&fixture, &persisted); - let staged = stage_image(&fixture, &draft_before_third); - let mut third_input = commit_input( - &fixture, - &draft_before_third, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - third_input.source_layer_id = Some(third_layer_id); - third_input.media_sha256 = Some(third_media_sha256); - commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery"); -} - -#[test] -fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() { - let (fixture, first_commit_id, second_commit_id) = - initialize_refine_fixture_with_later_commit(); - // initialize_refine_fixture_with_later_commit only forces the first - // transaction into the unresolved state. Restore both completed ledgers so - // this test models two successful commits followed by a restart. - for commit_id in [&first_commit_id, &second_commit_id] { - let mut journal = read_asset_canvas_journal(fixture.root(), commit_id) - .expect("read completed transaction journal") - .expect("completed transaction journal"); - let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id) - .expect("read completed transaction ledger") - .expect("completed transaction ledger"); - journal.stage = AssetCanvasTransactionStage::Committed; - ledger.status = AssetCanvasLedgerStatus::Committed; - write_asset_canvas_journal(fixture.root(), &journal) - .expect("restore completed journal stage"); - write_asset_canvas_ledger(fixture.root(), &ledger) - .expect("restore completed ledger status"); - } - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover two completed refine transactions"); - let first_outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == first_commit_id) - .expect("first completed outcome"); - let second_outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == second_commit_id) - .expect("second completed outcome"); - assert_eq!( - first_outcome.status, - RecoverAssetCanvasOutcomeStatus::Superseded - ); - assert_eq!( - second_outcome.status, - RecoverAssetCanvasOutcomeStatus::AlreadyCommitted - ); - assert!(!recovered.result.outcomes.iter().any(|outcome| { - outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - })); - - let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("repeat recovery after supersede"); - assert!(repeated.result.outcomes.iter().any(|outcome| { - outcome.commit_id == first_commit_id - && outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded - })); - assert!(!repeated.result.outcomes.iter().any(|outcome| { - outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - })); -} - -#[test] -fn recovery_follows_verified_refine_commit_chain_to_current_state() { - let (fixture, first_commit_id, second_commit_id) = - initialize_refine_fixture_with_later_commit(); - let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) - .expect("read first transaction before third commit") - .expect("first transaction retained"); - let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id) - .expect("read first ledger before third commit") - .expect("first ledger retained"); - first_journal.stage = AssetCanvasTransactionStage::Committed; - first_ledger.status = AssetCanvasLedgerStatus::Committed; - write_asset_canvas_journal(fixture.root(), &first_journal) - .expect("restore first journal before third commit"); - write_asset_canvas_ledger(fixture.root(), &first_ledger) - .expect("restore first ledger before third commit"); - let draft_after_second = - read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) - .expect("read draft after second commit") - .expect("draft retained after second commit"); - let (draft_before_third, third_layer_id, third_media_sha256) = - add_candidate_layer(&fixture, &draft_after_second); - let staged = stage_image(&fixture, &draft_before_third); - let mut third_input = commit_input( - &fixture, - &draft_before_third, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - third_input.source_layer_id = Some(third_layer_id); - third_input.media_sha256 = Some(third_media_sha256); - let third_commit_id = third_input.commit_id.clone(); - commit_asset_canvas_at(fixture.root(), &third_input).expect("commit third final image"); - force_refine_transaction_unresolved(&fixture, &first_commit_id); - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover three chained refine commits"); - for commit_id in [&first_commit_id, &second_commit_id] { - assert!(recovered.result.outcomes.iter().any(|outcome| { - outcome.commit_id == *commit_id - && outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded - })); - } - assert!(recovered.result.outcomes.iter().any(|outcome| { - outcome.commit_id == third_commit_id - && outcome.status == RecoverAssetCanvasOutcomeStatus::AlreadyCommitted - })); - assert!(!recovered.result.outcomes.iter().any(|outcome| { - outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - })); - - let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("repeat chained refine recovery"); - assert!(!repeated.result.outcomes.iter().any(|outcome| { - outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - })); -} - -#[test] -fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() { - let (fixture, first_commit_id, second_commit_id) = - initialize_refine_fixture_with_later_commit(); - let mut second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id) - .expect("read later journal") - .expect("later journal retained"); - second_journal.expected_project_revision += 1; - write_asset_canvas_journal(fixture.root(), &second_journal) - .expect("break later transaction chain"); - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover broken later chain"); - let first_outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == first_commit_id) - .expect("first recovery outcome"); - assert_eq!( - first_outcome.status, - RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - ); - let first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) - .expect("read retained first journal") - .expect("retained first journal"); - assert!(fixture - .root() - .join(&first_journal.final_image_relative_path) - .is_file()); -} - -#[test] -fn recovery_does_not_supersede_when_later_final_image_is_changed() { - let (fixture, _first_commit_id, second_commit_id) = - initialize_refine_fixture_with_later_commit(); - let second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id) - .expect("read later journal") - .expect("later journal retained"); - let replacement = png_bytes([240, 100, 30, 255]); - fs::write( - fixture - .root() - .join(&second_journal.final_image_relative_path), - &replacement, - ) - .expect("replace later final image"); - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover changed later final image"); - assert!(recovered.result.outcomes.iter().all(|outcome| { - outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - })); - assert_eq!( - fs::read( - fixture - .root() - .join(&second_journal.final_image_relative_path) - ) - .expect("retain unknown later final image"), - replacement - ); -} - -#[test] -fn recovery_keeps_rolled_back_transaction_rolled_back_when_later_commit_exists() { - let (fixture, first_commit_id, _second_commit_id) = - initialize_refine_fixture_with_later_commit(); - let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id) - .expect("read rolled-back journal") - .expect("rolled-back journal retained"); - let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id) - .expect("read rolled-back ledger") - .expect("rolled-back ledger retained"); - first_journal.stage = AssetCanvasTransactionStage::RolledBack; - first_ledger.status = AssetCanvasLedgerStatus::RolledBack; - write_asset_canvas_journal(fixture.root(), &first_journal) - .expect("persist rolled-back journal"); - write_asset_canvas_ledger(fixture.root(), &first_ledger).expect("persist rolled-back ledger"); - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover with rolled-back predecessor"); - let first_outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == first_commit_id) - .expect("rolled-back recovery outcome"); - assert_eq!( - first_outcome.status, - RecoverAssetCanvasOutcomeStatus::RolledBack - ); - assert_eq!(first_journal.stage, AssetCanvasTransactionStage::RolledBack); - assert_eq!(first_ledger.status, AssetCanvasLedgerStatus::RolledBack); -} - -#[test] -fn unresolved_refine_commit_blocks_and_then_clears_after_recovery() { - let fixture = initialize_refine_fixture(); - let (draft, first_layer_id, first_media_sha256) = add_candidate_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let mut first_input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - first_input.source_layer_id = Some(first_layer_id); - first_input.media_sha256 = Some(first_media_sha256); - commit_asset_canvas_at_internal( - fixture.root(), - &first_input, - Some(AssetCanvasCommitFaultStage::FileInstalled), - ) - .expect_err("interrupt first refine commit"); - - let draft_before_second = - read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id) - .expect("read draft after interruption") - .expect("draft retained after interruption"); - let (draft_before_second, second_layer_id, second_media_sha256) = - add_candidate_layer(&fixture, &draft_before_second); - let staged = stage_image(&fixture, &draft_before_second); - let mut second_input = commit_input( - &fixture, - &draft_before_second, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - second_input.source_layer_id = Some(second_layer_id); - second_input.media_sha256 = Some(second_media_sha256); - let error = commit_asset_canvas_at(fixture.root(), &second_input) - .expect_err("block same-asset commit before recovery"); - assert!(error.contains("未收尾的正式图提交"), "{error}"); - assert!( - read_asset_canvas_ledger(fixture.root(), &second_input.commit_id) - .expect("check blocked ledger") - .is_none() - ); - assert!(!fixture - .root() - .join(".agent/workbench/asset-canvas/transactions") - .join(&second_input.commit_id) - .exists()); - - recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover interrupted first commit"); - commit_asset_canvas_at(fixture.root(), &second_input).expect("commit after safe recovery"); -} - -#[test] -fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() { - let directory = tempfile::tempdir().expect("create refine discovery fixture"); - let root = directory.path(); - init_local_game_project_at(root, PROJECT_ID, "精修草稿发现测试项目") - .expect("initialize project"); - let source_bytes = png_bytes([80, 120, 160, 255]); - fs::write(root.join("assets/source.png"), source_bytes).expect("write source asset"); - let manifest_path = root.join(".agent/manifest.json"); - let mut manifest = read_manifest(&manifest_path).expect("read manifest"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "source-asset".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }); - write_manifest(&manifest_path, &manifest).expect("register source asset"); - let discovery = DiscoverAssetCanvasDraftInput { - project_path: project_path(root), - expected_project_id: PROJECT_ID.to_string(), - intent: AssetCanvasIntent::Refine, - source_asset_id: Some("source-asset".to_string()), - }; - let first_id = Uuid::new_v4().to_string(); - create_asset_canvas_draft_at( - root, - &create_input( - root, - &first_id, - AssetCanvasIntent::Refine, - Some("source-asset".to_string()), - ), - ) - .expect("create first refine draft"); - - let found = discover_asset_canvas_draft_at(root, &discovery).expect("discover refine draft"); - assert_eq!(found.status, DiscoverAssetCanvasDraftStatus::Found); - assert_eq!(found.draft.expect("found draft").draft_id, first_id); - - let second_id = Uuid::new_v4().to_string(); - create_asset_canvas_draft_at( - root, - &create_input( - root, - &second_id, - AssetCanvasIntent::Refine, - Some("source-asset".to_string()), - ), - ) - .expect("create second refine draft"); - let error = discover_asset_canvas_draft_at(root, &discovery) - .expect_err("ambiguous refine drafts must fail closed"); - assert!(error.contains("多个可恢复素材画布草稿")); -} - -#[test] -fn draft_cas_continuous_saves_and_trusted_restart_recovery() { - let fixture = initialize_fixture(); - let mut draft = fixture.draft.clone(); - for offset in [10.0, 20.0, 30.0] { - let mut canvas = draft.canvas.clone(); - canvas.viewport.x = offset; - let result = update_asset_canvas_draft_at( - fixture.root(), - &UpdateAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: Vec::new(), - }, - ) - .expect("continuous draft save"); - assert_eq!(result.status, UpdateAssetCanvasDraftStatus::Updated); - draft = result.draft; - } - assert_eq!(draft.revision, 3); - fs::write( - fixture - .root() - .join(asset_canvas_draft_relative_path(&draft.draft_id)), - b"{broken", - ) - .expect("corrupt primary draft"); - let recovered = read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - }, - ) - .expect("recover trusted draft") - .draft - .expect("recovered draft"); - assert_eq!(recovered.revision, 2); - assert_eq!(recovered.canvas.viewport.x, 20.0); - - let mut canvas = recovered.canvas.clone(); - canvas.viewport.x = 40.0; - update_asset_canvas_draft_at( - fixture.root(), - &UpdateAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: recovered.draft_id.clone(), - expected_draft_revision: recovered.revision, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: Vec::new(), - }, - ) - .expect("write another draft revision"); - fs::write( - fixture - .root() - .join(asset_canvas_recovery_sha_relative_path(&recovered.draft_id)), - format!("{}\n", "0".repeat(64)), - ) - .expect("corrupt recovery digest"); - fs::write( - fixture - .root() - .join(asset_canvas_draft_relative_path(&recovered.draft_id)), - b"{broken-again", - ) - .expect("corrupt primary draft again"); - assert!(read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: recovered.draft_id, - }, - ) - .expect_err("reject untrusted recovery digest") - .contains("reconciliation-required")); -} - -#[test] -fn draft_media_total_limit_rejects_additional_import() { - let fixture = initialize_fixture(); - let media_directory = fixture.root().join(format!( - "{ASSET_CANVAS_ROOT}/media/{}", - fixture.draft.draft_id - )); - fs::create_dir_all(&media_directory).expect("create draft media directory"); - let sparse = fs::File::create(media_directory.join("existing.bin")) - .expect("create sparse media fixture"); - sparse - .set_len(ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES) - .expect("size sparse media fixture"); - assert!(store_asset_canvas_media_at( - fixture.root(), - &StoreAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - media_type: "image/png".to_string(), - bytes: fixture.png.clone(), - }, - ) - .expect_err("reject draft media total overflow") - .contains("512 MiB")); -} - -#[test] -fn same_draft_revision_concurrent_cas_has_exactly_one_winner() { - let fixture = initialize_fixture(); - let root = fixture.root().to_path_buf(); - let draft = fixture.draft.clone(); - let barrier = Arc::new(Barrier::new(3)); - let mut threads = Vec::new(); - for x in [101.0, 202.0] { - let root = root.clone(); - let draft = draft.clone(); - let barrier = Arc::clone(&barrier); - threads.push(std::thread::spawn(move || { - let mut canvas = draft.canvas.clone(); - canvas.viewport.x = x; - barrier.wait(); - update_asset_canvas_draft_at( - &root, - &UpdateAssetCanvasDraftInput { - project_path: project_path(&root), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id, - expected_draft_revision: 0, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: Vec::new(), - }, - ) - .expect("concurrent CAS") - .status - })); - } - barrier.wait(); - let statuses = threads - .into_iter() - .map(|thread| thread.join().expect("join CAS writer")) - .collect::>(); - assert_eq!( - statuses - .iter() - .filter(|status| **status == UpdateAssetCanvasDraftStatus::Updated) - .count(), - 1 - ); - assert_eq!( - statuses - .iter() - .filter(|status| **status == UpdateAssetCanvasDraftStatus::Conflict) - .count(), - 1 - ); -} - -#[test] -fn same_project_revision_double_commit_cannot_overwrite() { - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged_one = stage_image(&fixture, &draft); - let staged_two = stage_image(&fixture, &draft); - let first = commit_input( - &fixture, - &draft, - &staged_one, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - let mut second = commit_input( - &fixture, - &draft, - &staged_two, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - second.expected_revision = first.expected_revision; - assert!(matches!( - commit_asset_canvas_at(fixture.root(), &first) - .expect("first writer") - .result, - CommitAssetCanvasResult::Committed { .. } - )); - assert!(matches!( - commit_asset_canvas_at(fixture.root(), &second) - .expect("second writer") - .result, - CommitAssetCanvasResult::Conflict { - conflict_kind: AssetCanvasConflictKind::ProjectRevision, - .. - } - )); - assert_eq!( - current_asset_canvas_manifest(fixture.root()) - .expect("manifest") - .assets - .iter() - .filter(|asset| asset.id.starts_with("canvas-")) - .count(), - 1 - ); -} - -#[test] -fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { - for fault in [ - AssetCanvasCommitFaultStage::FirstSnapshotInstalled, - AssetCanvasCommitFaultStage::SnapshotsInstalled, - AssetCanvasCommitFaultStage::JournalInstalled, - AssetCanvasCommitFaultStage::Prepared, - AssetCanvasCommitFaultStage::FileInstalled, - AssetCanvasCommitFaultStage::ManifestInstalled, - AssetCanvasCommitFaultStage::RevisionInstalled, - AssetCanvasCommitFaultStage::Verified, - AssetCanvasCommitFaultStage::LedgerCommitted, - ] { - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - let error = commit_asset_canvas_at_internal(fixture.root(), &input, Some(fault)) - .expect_err("inject commit fault"); - assert!(error.starts_with("fault-injected:")); - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover injected fault"); - let outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == input.commit_id) - .expect("recovery outcome"); - match fault { - AssetCanvasCommitFaultStage::FirstSnapshotInstalled - | AssetCanvasCommitFaultStage::SnapshotsInstalled - | AssetCanvasCommitFaultStage::JournalInstalled - | AssetCanvasCommitFaultStage::Prepared - | AssetCanvasCommitFaultStage::FileInstalled => { - assert_eq!(outcome.status, RecoverAssetCanvasOutcomeStatus::RolledBack); - assert!(!fixture - .root() - .join(format!("assets/canvas/测试素材--{}.png", input.commit_id)) - .exists()); - let recovered_draft = read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - }, - ) - .expect("read rolled back draft") - .draft - .expect("rolled back draft retained"); - assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Editing); - assert_eq!(recovered_draft.revision, draft.revision); - assert!(recovered_draft.pending_commit.is_none()); - if matches!( - fault, - AssetCanvasCommitFaultStage::FirstSnapshotInstalled - | AssetCanvasCommitFaultStage::SnapshotsInstalled - | AssetCanvasCommitFaultStage::JournalInstalled - ) { - assert!(!fixture - .root() - .join(format!( - ".agent/workbench/asset-canvas/transactions/{}", - input.commit_id - )) - .exists()); - } - } - _ => { - assert!(matches!( - outcome.status, - RecoverAssetCanvasOutcomeStatus::Committed - | RecoverAssetCanvasOutcomeStatus::AlreadyCommitted - )); - assert_eq!( - read_game_creator_agent_runtime_project_revision(fixture.root()) - .expect("revision after recovery") - .revision, - 1 - ); - let recovered_draft = read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - }, - ) - .expect("read committed draft") - .draft - .expect("committed draft retained"); - assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Editing); - assert_eq!(recovered_draft.revision, draft.revision + 1); - assert!(recovered_draft.pending_commit.is_none()); - assert_eq!( - recovered_draft - .last_commit - .as_ref() - .map(|last| last.commit_id.as_str()), - Some(input.commit_id.as_str()) - ); - } - } - } -} - -#[test] -fn unpublished_snapshot_transaction_recovers_and_replays_the_same_idempotency_identity() { - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - commit_asset_canvas_at_internal( - fixture.root(), - &input, - Some(AssetCanvasCommitFaultStage::SnapshotsInstalled), - ) - .expect_err("stop before journal publication"); - - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("clean unpublished transaction"); - assert_eq!( - recovered.result.outcomes, - vec![RecoverAssetCanvasOutcome { - commit_id: input.commit_id.clone(), - status: RecoverAssetCanvasOutcomeStatus::RolledBack, - event_id: None, - asset_id: None, - }] - ); - - assert!(matches!( - commit_asset_canvas_at(fixture.root(), &input) - .expect("replay same idempotency identity after cleanup") - .result, - CommitAssetCanvasResult::Committed { .. } - )); - assert!(matches!( - commit_asset_canvas_at(fixture.root(), &input) - .expect("repeat committed identity") - .result, - CommitAssetCanvasResult::AlreadyCommitted { .. } - )); - assert_eq!( - current_asset_canvas_manifest(fixture.root()) - .expect("manifest after replay") - .assets - .iter() - .filter(|asset| asset.id.starts_with("canvas-")) - .count(), - 1 - ); -} - -#[test] -fn mismatched_installed_file_requires_reconciliation_and_is_not_deleted() { - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - commit_asset_canvas_at_internal( - fixture.root(), - &input, - Some(AssetCanvasCommitFaultStage::FileInstalled), - ) - .expect_err("stop after final file installation"); - let final_path = fixture - .root() - .join(format!("assets/canvas/测试素材--{}.png", input.commit_id)); - let replacement = png_bytes([220, 70, 10, 255]); - fs::write(&final_path, &replacement).expect("replace installed bytes"); - let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) - .expect("recover mismatched final file"); - let outcome = recovered - .result - .outcomes - .iter() - .find(|outcome| outcome.commit_id == input.commit_id) - .expect("mismatched recovery outcome"); - assert_eq!( - outcome.status, - RecoverAssetCanvasOutcomeStatus::ReconciliationRequired - ); - assert_eq!( - fs::read(final_path).expect("retain unknown file"), - replacement - ); -} - -#[cfg(unix)] -#[test] -fn rejects_symlinked_or_hardlinked_media_at_source_staging_and_final_paths() { - use std::os::unix::fs::symlink; - - let source_directory = tempfile::tempdir().expect("create linked source fixture"); - init_local_game_project_at(source_directory.path(), PROJECT_ID, "链接源资源测试") - .expect("initialize linked source project"); - let real_source = source_directory.path().join("assets/real.png"); - fs::write(&real_source, png_bytes([1, 2, 3, 255])).expect("write real source"); - let source_path = source_directory.path().join("assets/source.png"); - symlink("real.png", &source_path).expect("symlink source asset"); - let manifest_path = source_directory.path().join(".agent/manifest.json"); - let mut manifest = read_manifest(&manifest_path).expect("read linked source manifest"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "linked-source".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }); - write_manifest(&manifest_path, &manifest).expect("register linked source"); - assert!(create_asset_canvas_draft_at( - source_directory.path(), - &create_input( - source_directory.path(), - &Uuid::new_v4().to_string(), - AssetCanvasIntent::Refine, - Some("linked-source".to_string()), - ), - ) - .is_err()); - fs::remove_file(&source_path).expect("remove source symlink"); - fs::hard_link(&real_source, &source_path).expect("hardlink source asset"); - assert!(create_asset_canvas_draft_at( - source_directory.path(), - &create_input( - source_directory.path(), - &Uuid::new_v4().to_string(), - AssetCanvasIntent::Refine, - Some("linked-source".to_string()), - ), - ) - .expect_err("reject hardlinked source asset") - .contains("硬链接")); - - let fixture = initialize_fixture(); - let draft = add_imported_layer(&fixture, &fixture.draft); - let staged = stage_image(&fixture, &draft); - let staging_image = fixture.root().join(format!( - "{ASSET_CANVAS_ROOT}/staging/{}/image.png", - staged.staged_image_token.as_deref().expect("staging token") - )); - fs::hard_link(&staging_image, fixture.root().join("staging-hardlink.png")) - .expect("hardlink staging image"); - let staging_input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - assert!(commit_asset_canvas_at(fixture.root(), &staging_input) - .expect_err("reject hardlinked staging image") - .contains("硬链接")); - - fs::remove_file(fixture.root().join("staging-hardlink.png")).expect("remove staging hardlink"); - let final_input = commit_input( - &fixture, - &draft, - &staged, - Uuid::new_v4().to_string(), - Uuid::new_v4().to_string(), - ); - let final_path = fixture.root().join(format!( - "assets/canvas/测试素材--{}.png", - final_input.commit_id - )); - fs::create_dir_all(final_path.parent().expect("final parent")).expect("create final parent"); - symlink("../occupied.png", &final_path).expect("symlink final target"); - assert!(commit_asset_canvas_at(fixture.root(), &final_input).is_err()); - assert!(fs::symlink_metadata(final_path) - .expect("final symlink retained") - .file_type() - .is_symlink()); -} - -#[test] -fn rejects_bad_signature_oversize_identity_replacement_and_linked_sidecars() { - let fixture = initialize_fixture(); - let bad_signature = store_asset_canvas_media_at( - fixture.root(), - &StoreAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: 0, - media_type: "image/png".to_string(), - bytes: b"not a png".to_vec(), - }, - ) - .expect_err("reject bad signature"); - assert!(bad_signature.contains("签名")); - assert!(store_asset_canvas_media_at( - fixture.root(), - &StoreAssetCanvasMediaInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: 0, - media_type: "image/png".to_string(), - bytes: vec![0_u8; ASSET_CANVAS_MAX_MEDIA_BYTES + 1], - }, - ) - .expect_err("reject oversized image") - .contains("大小")); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let manifest_path = fixture.root().join(".agent/manifest.json"); - let manifest = read_manifest(&manifest_path).expect("read manifest"); - let draft_path = fixture - .root() - .join(asset_canvas_draft_relative_path(&fixture.draft.draft_id)); - let hardlink = fixture.root().join("draft-hardlink.json"); - fs::hard_link(&draft_path, &hardlink).expect("hardlink draft"); - let error = read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - }, - ) - .expect_err("reject hardlinked sidecar"); - assert!(error.contains("reconciliation-required")); - fs::remove_file(hardlink).expect("remove hardlink fixture"); - fs::remove_file(&draft_path).expect("remove primary for symlink fixture"); - symlink( - "../../../../assets/missing.png", - fixture - .root() - .join(asset_canvas_draft_relative_path(&fixture.draft.draft_id)), - ) - .expect("symlink draft"); - assert!(read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - }, - ) - .is_err()); - fs::remove_file( - fixture - .root() - .join(asset_canvas_draft_relative_path(&fixture.draft.draft_id)), - ) - .expect("remove symlink"); - let mut replaced = manifest; - replaced.project_id = "replacement-project".to_string(); - write_manifest(&manifest_path, &replaced).expect("replace project identity"); - let result = read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - }, - ) - .expect("typed identity conflict"); - assert_eq!( - result.status, - ReadAssetCanvasDraftStatus::ProjectIdentityConflict - ); - } -} - -#[test] -fn ordinary_draft_update_preserves_backend_authoritative_generation_records() { - let fixture = initialize_fixture(); - let mut authoritative = fixture.draft.clone(); - authoritative.generations.push(AssetCanvasGenerationRecord { - generation_id: Uuid::new_v4().to_string(), - intent_id: Uuid::new_v4().to_string(), - phase: AssetCanvasGenerationStatus::GenerationRunning, - reference_resource_ids: Vec::new(), - output_asset_id: None, - source_layer_id: None, - placeholder: None, - error_code: None, - created_at: 1, - updated_at: 2, - idempotency_key: None, - status: None, - prompt: None, - operation_id: None, - output_media_ids: Vec::new(), - }); - write_asset_canvas_draft_locked(fixture.root(), &authoritative) - .expect("write authoritative generation record"); - - let mut canvas = authoritative.canvas.clone(); - canvas.viewport.x = 42.0; - let updated = update_asset_canvas_draft_at( - fixture.root(), - &UpdateAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: authoritative.draft_id.clone(), - expected_draft_revision: authoritative.revision, - status: AssetCanvasDraftStatus::Editing, - canvas, - generations: Vec::new(), - }, - ) - .expect("save stale frontend draft"); - assert_eq!(updated.draft.generations, authoritative.generations); - assert_eq!(updated.draft.canvas.viewport.x, 42.0); -} - -#[test] -fn imports_multiple_images_as_one_authoritative_draft_revision() { - let fixture = initialize_fixture(); - let first = fixture.root().join("first.png"); - let second = fixture.root().join("second.png"); - fs::write(&first, png_bytes([10, 20, 30, 255])).expect("write first import"); - fs::write(&second, png_bytes([40, 50, 60, 255])).expect("write second import"); - - let result = import_asset_canvas_images_at( - fixture.root(), - &ImportAssetCanvasImagesInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - viewport_width: 900.0, - viewport_height: 640.0, - }, - &[first, second], - ) - .expect("import image batch"); - - assert_eq!(result.status, ImportAssetCanvasImagesStatus::Imported); - let draft = result.draft.expect("authoritative imported draft"); - assert_eq!(draft.revision, fixture.draft.revision + 1); - assert_eq!(draft.canvas.layers.len(), 2); - assert_eq!(result.imported_layer_ids.len(), 2); - assert_eq!(draft.canvas.selected_layer_ids, result.imported_layer_ids); - assert_eq!( - draft.canvas.primary_selected_layer_id, - result.imported_layer_ids.last().cloned() - ); - assert!(draft - .canvas - .layers - .iter() - .all(|layer| matches!(layer.media_ref, AssetCanvasMediaRef::DraftMedia { .. }))); - assert_eq!( - fs::read_dir( - fixture - .root() - .join(format!("{ASSET_CANVAS_ROOT}/media/{}", draft.draft_id)) - ) - .expect("read imported media directory") - .count(), - 2 - ); -} - -#[test] -fn rejects_invalid_import_batch_without_partial_media_or_revision_change() { - let fixture = initialize_fixture(); - let valid = fixture.root().join("valid.png"); - let invalid = fixture.root().join("invalid.png"); - fs::write(&valid, &fixture.png).expect("write valid import"); - fs::write(&invalid, b"not an image").expect("write invalid import"); - - let error = import_asset_canvas_images_at( - fixture.root(), - &ImportAssetCanvasImagesInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision, - viewport_width: 900.0, - viewport_height: 640.0, - }, - &[valid, invalid], - ) - .expect_err("invalid image rejects whole batch"); - assert!(error.contains("PNG、JPEG 和 WebP")); - - let reread = read_asset_canvas_draft_at( - fixture.root(), - &ReadAssetCanvasDraftInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - }, - ) - .expect("read unchanged draft") - .draft - .expect("draft remains"); - assert_eq!(reread.revision, fixture.draft.revision); - assert!(reread.canvas.layers.is_empty()); - let media_directory = fixture.root().join(format!( - "{ASSET_CANVAS_ROOT}/media/{}", - fixture.draft.draft_id - )); - assert!(!media_directory.exists()); -} - -#[test] -fn import_revision_overflow_does_not_install_partial_media() { - let fixture = initialize_fixture(); - let image = fixture.root().join("revision-overflow.png"); - fs::write(&image, &fixture.png).expect("write revision overflow import"); - let mut draft = fixture.draft.clone(); - draft.revision = ASSET_CANVAS_MAX_SAFE_INTEGER; - write_asset_canvas_draft_locked(fixture.root(), &draft).expect("write max revision draft"); - - let error = import_asset_canvas_images_at( - fixture.root(), - &ImportAssetCanvasImagesInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: draft.draft_id.clone(), - expected_draft_revision: draft.revision, - viewport_width: 900.0, - viewport_height: 640.0, - }, - &[image], - ) - .expect_err("revision overflow rejects import before media install"); - assert!(error.contains("revision")); - - let media_directory = fixture - .root() - .join(format!("{ASSET_CANVAS_ROOT}/media/{}", draft.draft_id)); - assert!(!media_directory.exists()); - let reread = read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &draft.draft_id) - .expect("read max revision draft") - .expect("max revision draft remains"); - assert_eq!(reread, draft); -} - -#[test] -fn import_conflict_returns_authoritative_draft_without_writing_media() { - let fixture = initialize_fixture(); - let image = fixture.root().join("conflict.png"); - fs::write(&image, &fixture.png).expect("write conflict import"); - let result = import_asset_canvas_images_at( - fixture.root(), - &ImportAssetCanvasImagesInput { - project_path: project_path(fixture.root()), - expected_project_id: PROJECT_ID.to_string(), - draft_id: fixture.draft.draft_id.clone(), - expected_draft_revision: fixture.draft.revision + 1, - viewport_width: 900.0, - viewport_height: 640.0, - }, - &[image], - ) - .expect("typed import conflict"); - assert_eq!(result.status, ImportAssetCanvasImagesStatus::Conflict); - assert_eq!(result.draft, Some(fixture.draft.clone())); - assert!(result.imported_layer_ids.is_empty()); -}