diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index a3195c7fa..2b91952dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -6,6 +6,10 @@ use std::path::PathBuf; use std::sync::{Arc, Weak}; use uuid::Uuid; +mod error; + +pub(crate) use error::ResourceEditError; + const RESOURCE_EDIT_SCHEMA_VERSION: &str = "game-creator-resource-edit.v1"; const RESOURCE_EDIT_LEDGER_MAX_BYTES: usize = 512 * 1024; const RESOURCE_EDIT_TEXT_MAX_BYTES: usize = 2 * 1024 * 1024; @@ -484,6 +488,8 @@ struct ResourceEditLedger { remote_slice_warnings: Vec, remote_asset_object_id: Option, remote_model: Option, + // TODO clean unnecessary:字段无 `skip_serializing_if`,每个已落盘账本都带这个 key, + // 而 `ResourceEditLedger` 是 `deny_unknown_fields`;直接删会让扫描时旧账本解析失败。 #[serde(default)] terminal_failure_code: Option, #[serde(default)] @@ -2801,10 +2807,8 @@ async fn submit_resource_edit_remote( return Err(error.clone()); } return Err(match editor_api_rejection_reason(response).await { - Some(reason) => { - format!("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400({reason})") - } - None => "remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string(), + Some(reason) => format!("资源编辑请求被拒绝:HTTP 400({reason})"), + None => "资源编辑请求被拒绝:HTTP 400".to_string(), }); } if !matches!( @@ -2875,11 +2879,12 @@ async fn wait_for_resource_edit_remote( client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, -) -> Result { +) -> Result { let operation_id = ledger .remote_operation_id .as_deref() - .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; + .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string()) + .map_err(ResourceEditError::Other)?; let status_url = format!( "{}{}", access.api_base_url(), @@ -2889,39 +2894,50 @@ async fn wait_for_resource_edit_remote( let mut poll_after_ms = 1_000; loop { if started_at.elapsed() >= Duration::from_secs(35 * 60) { - return Err("result-unknown: 资源编辑任务仍在执行,已停止本地等待".to_string()); + return Err(ResourceEditError::Other( + "result-unknown: 资源编辑任务仍在执行,已停止本地等待".to_string(), + )); } - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let response = crate::http_client::with_agc_main_site_marker( client.get(&status_url).bearer_auth(access.bearer_token()), ) .send() .await - .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| { + ResourceEditError::Other("result-unknown: 查询资源编辑任务失败".to_string()) + })?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err(editor_api_authentication_error()); + return Err(ResourceEditError::Other(editor_api_authentication_error())); } if response.status() == reqwest::StatusCode::FORBIDDEN { - return Err(editor_api_authorization_error()); + return Err(ResourceEditError::Other(editor_api_authorization_error())); } if [429, 502, 503, 504].contains(&response.status().as_u16()) { poll_after_ms = 2_000; continue; } if !response.status().is_success() { - return Err(format!( + return Err(ResourceEditError::Other(format!( "result-unknown: 查询资源编辑任务返回 HTTP {}", response.status().as_u16() - )); + ))); } - let payload = response - .json::() - .await - .map_err(|_| "result-unknown: 资源编辑任务响应无法解析".to_string())?; - access.validate_frozen_session()?; + let payload = response.json::().await.map_err(|_| { + ResourceEditError::Other("result-unknown: 资源编辑任务响应无法解析".to_string()) + })?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let job = platform_generation_status_data(&payload); match json_string_field(job, "status").as_deref() { Some("completed") => { @@ -2929,7 +2945,11 @@ async fn wait_for_resource_edit_remote( .get("result") .filter(|value| !value.is_null()) .cloned() - .ok_or_else(|| "result-unknown: 资源编辑任务完成但缺少 result".to_string())?; + .ok_or_else(|| { + ResourceEditError::Other( + "result-unknown: 资源编辑任务完成但缺少 result".to_string(), + ) + })?; if let (Some(result), Some(warning)) = ( result.as_object_mut(), job.get("warning").filter(|value| !value.is_null()), @@ -2937,20 +2957,28 @@ async fn wait_for_resource_edit_remote( result.insert("_queryWarning".to_string(), warning.clone()); } if !resource_edit_result_has_download(&result) { - return Err("result-unknown: 资源编辑结果缺少可下载媒体".to_string()); + return Err(ResourceEditError::Other( + "result-unknown: 资源编辑结果缺少可下载媒体".to_string(), + )); } return Ok(result); } Some("failed") => { + let server_message = json_string_field(job, "error"); ledger.terminal_failure_code = Some("remote-generation-failed".to_string()); ledger.terminal_failed_at = Some(unix_timestamp()); - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; - return Err("remote-terminal-failed: 资源编辑生成失败".to_string()); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed) + .map_err(ResourceEditError::Other)?; + return Err(ResourceEditError::RemoteGenerationFailed { server_message }); } Some("queued" | "running") => { poll_after_ms = external_generation_poll_after_ms(job); } - _ => return Err("result-unknown: 资源编辑任务状态无效".to_string()), + _ => { + return Err(ResourceEditError::Other( + "result-unknown: 资源编辑任务状态无效".to_string(), + )) + } } } } @@ -3371,19 +3399,24 @@ async fn prepare_remote_resource_edit( ledger: &mut ResourceEditLedger, access: &ExternalEditorBindingAccess<'_>, principal: &ExternalEditorPrincipalIdentity, -) -> Result<(), String> { - bind_resource_edit_platform_owner(ledger, access.frozen_platform_session())?; - access.validate_frozen_session()?; +) -> Result<(), ResourceEditError> { + bind_resource_edit_platform_owner(ledger, access.frozen_platform_session()) + .map_err(ResourceEditError::Other)?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; match prepare_resource_edit_service_identity( root, ledger, access.api_base_url(), access.bearer_token(), access.frozen_platform_session(), - )? { + ) + .map_err(ResourceEditError::Other)? + { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { - return Err("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string()) + return Err(ResourceEditError::Other("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string())) } } if ledger @@ -3391,14 +3424,14 @@ async fn prepare_remote_resource_edit( .as_deref() .is_some_and(|endpoint| !is_external_resource_edit_endpoint(endpoint)) { - return Err( + return Err(ResourceEditError::Other( "result-unknown: 历史站内资源编辑 operation 不能由 External v1 自动重放".to_string(), - ); + )); } let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() - .map_err(|_| "无法创建资源编辑 HTTP 客户端".to_string())?; + .map_err(|_| ResourceEditError::Other("无法创建资源编辑 HTTP 客户端".to_string()))?; let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { serde_json::json!({ "imageSrc": legacy_public_path }) @@ -3407,13 +3440,19 @@ async fn prepare_remote_resource_edit( "objectKey": ledger .remote_object_key .clone() - .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? + .ok_or_else(|| { + ResourceEditError::Other( + "远端已完成的资源编辑缺少稳定 objectKey".to_string(), + ) + })? }) } } else { let generated = if ledger.remote_operation_id.is_some() { let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; result } else { let prepared_source = if matches!( @@ -3428,7 +3467,8 @@ async fn prepare_remote_resource_edit( ensure_resource_edit_source_reference( root, &client, access, principal, input, source, ledger, ) - .await?, + .await + .map_err(ResourceEditError::Other)?, ) } else { None @@ -3441,7 +3481,7 @@ async fn prepare_remote_resource_edit( // never carry another owner's resourceId, objectKey, or projectId. ledger.endpoint = None; ledger.request_body_json = None; - write_resource_edit_ledger(root, ledger)?; + write_resource_edit_ledger(root, ledger).map_err(ResourceEditError::Other)?; } if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { let canvas_context = if let Some(prepared) = prepared_source.as_ref() { @@ -3454,7 +3494,11 @@ async fn prepare_remote_resource_edit( | LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic ) { - Some(prepare_external_canvas_generation_context(root, &client, access).await?) + Some( + prepare_external_canvas_generation_context(root, &client, access) + .await + .map_err(ResourceEditError::Other)?, + ) } else { None }; @@ -3467,12 +3511,13 @@ async fn prepare_remote_resource_edit( .as_ref() .map(|prepared| prepared.stable_reference.as_str()), canvas_context.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; let mut body = body; if let Some(prepared) = prepared_source.as_ref() { - let body_object = body - .as_object_mut() - .ok_or_else(|| "资源编辑生成请求正文必须是 object".to_string())?; + let body_object = body.as_object_mut().ok_or_else(|| { + ResourceEditError::Other("资源编辑生成请求正文必须是 object".to_string()) + })?; body_object.insert( "projectId".to_string(), serde_json::Value::String(prepared.canvas_context.project_id.clone()), @@ -3484,18 +3529,22 @@ async fn prepare_remote_resource_edit( ledger.remote_project_id = Some(prepared.canvas_context.project_id.clone()); } ledger.endpoint = Some(endpoint.to_string()); - ledger.request_body_json = Some( - serde_json::to_string(&body) - .map_err(|error| format!("序列化资源编辑生成请求失败:{error}"))?, - ); - write_resource_edit_ledger(root, ledger)?; + ledger.request_body_json = Some(serde_json::to_string(&body).map_err(|error| { + ResourceEditError::Other(format!("序列化资源编辑生成请求失败:{error}")) + })?); + write_resource_edit_ledger(root, ledger).map_err(ResourceEditError::Other)?; } - submit_and_persist_resource_edit_remote(root, &client, access, ledger).await?; + submit_and_persist_resource_edit_remote(root, &client, access, ledger) + .await + .map_err(ResourceEditError::Other)?; let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; result }; - let identity = extract_resource_edit_remote_identity(&generated, input.edit_kind)?; + let identity = extract_resource_edit_remote_identity(&generated, input.edit_kind) + .map_err(ResourceEditError::Other)?; ledger.remote_resource_id = identity.resource_id; ledger.remote_object_key = identity.object_key.clone(); ledger.remote_legacy_public_path = identity.legacy_public_path.clone(); @@ -3503,8 +3552,9 @@ async fn prepare_remote_resource_edit( .sequence_frames .as_ref() .map(|frames| { - serde_json::to_string(frames) - .map_err(|error| format!("序列化角色动画序列帧失败:{error}")) + serde_json::to_string(frames).map_err(|error| { + ResourceEditError::Other(format!("序列化角色动画序列帧失败:{error}")) + }) }) .transpose()?; ledger.remote_sequence_duration_ms = identity.sequence_duration_ms; @@ -3519,18 +3569,23 @@ async fn prepare_remote_resource_edit( ledger.remote_slice_warnings = identity.slice_warnings; ledger.remote_asset_object_id = identity.asset_object_id; ledger.remote_model = identity.model; - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteCompleted)?; + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteCompleted) + .map_err(ResourceEditError::Other)?; if let Some(legacy_public_path) = identity.legacy_public_path { serde_json::json!({ "imageSrc": legacy_public_path }) } else { serde_json::json!({ "objectKey": identity .object_key - .ok_or_else(|| "资源编辑结果缺少稳定 objectKey".to_string())? + .ok_or_else(|| { + ResourceEditError::Other("资源编辑结果缺少稳定 objectKey".to_string()) + })? }) } }; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let download = resolve_canvas_resource_download_with_access( &client, access, @@ -3538,15 +3593,22 @@ async fn prepare_remote_resource_edit( media_read_limit(&input.edit_kind), ) .await - .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? - .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| { + ResourceEditError::Other("result-unknown: 远端资源编辑结果下载或换签失败".to_string()) + })? + .ok_or_else(|| ResourceEditError::Other("远端资源编辑结果缺少可下载媒体".to_string()))?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let (media_type, extension) = - validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; - write_resource_edit_staging(root, &input.operation_id, &download.bytes)?; + validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes) + .map_err(ResourceEditError::Other)?; + write_resource_edit_staging(root, &input.operation_id, &download.bytes) + .map_err(ResourceEditError::Other)?; ledger.staged_media_type = Some(media_type); ledger.staged_extension = Some(extension); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::MediaDownloaded) + .map_err(ResourceEditError::Other) } fn remove_resource_edit_staging(root: &Path, operation_id: &str) -> Result<(), String> { @@ -5317,25 +5379,43 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( }) } +/// 未 typed 化入口:Tauri 命令与既有调用点仍按 `String` 取文案。 +// TODO refactor string-typed pub(crate) async fn resume_local_project_resource_edit_at( input: ResumeLocalProjectResourceEditInput, ) -> Result { - validate_resource_edit_uuid(&input.operation_id, "operationId")?; + resume_local_project_resource_edit_typed(input) + .await + .map_err(|error| error.to_user_msg()) +} + +/// 「待恢复资源编辑」的恢复实现:错误带 typed 分类,远端终止失败不在这一层压成文案。 +pub(crate) async fn resume_local_project_resource_edit_typed( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId") + .map_err(ResourceEditError::Other)?; let root = Path::new(input.project_path.trim()); - validate_project_root(root)?; - let manifest = read_existing_manifest_for_project(root)?; + validate_project_root(root).map_err(ResourceEditError::Other)?; + let manifest = read_existing_manifest_for_project(root).map_err(ResourceEditError::Other)?; if manifest.project_id != input.expected_project_id { - return Err("project-identity-conflict".to_string()); + return Err(ResourceEditError::Other( + "project-identity-conflict".to_string(), + )); } - let ledger = read_resource_edit_ledger(root, &input.operation_id)? - .ok_or_else(|| "待恢复的资源编辑账本不存在".to_string())?; + let ledger = read_resource_edit_ledger(root, &input.operation_id) + .map_err(ResourceEditError::Other)? + .ok_or_else(|| "待恢复的资源编辑账本不存在".to_string()) + .map_err(ResourceEditError::Other)?; if ledger.schema_version != RESOURCE_EDIT_SCHEMA_VERSION || ledger.project_id != input.expected_project_id || ledger.operation_id != input.operation_id { - return Err("待恢复的资源编辑账本身份无效".to_string()); + return Err(ResourceEditError::Other( + "待恢复的资源编辑账本身份无效".to_string(), + )); } - ensure_resource_edit_phase_resumable(&ledger)?; + ensure_resource_edit_phase_resumable(&ledger).map_err(ResourceEditError::Other)?; let source_asset = ledger .source_asset_id .as_deref() @@ -5389,7 +5469,7 @@ pub(crate) async fn resume_local_project_resource_edit_at( .to_string(), ) }); - derive_local_project_resource_at(DeriveLocalProjectResourceInput { + derive_local_project_resource_typed(DeriveLocalProjectResourceInput { project_path: input.project_path, expected_project_id: input.expected_project_id, expected_project_revision: ledger.expected_project_revision, @@ -5502,47 +5582,79 @@ pub(crate) async fn run_local_project_audio_generation_at( Ok(result.asset.map(|asset| asset.id)) } +/// 未 typed 化入口:Tauri 命令与既有调用点仍按 `String` 取文案。 +// TODO refactor string-typed pub(crate) async fn derive_local_project_resource_at( input: DeriveLocalProjectResourceInput, ) -> Result { - validate_resource_edit_uuid(&input.operation_id, "operationId")?; - validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey")?; + derive_local_project_resource_typed(input) + .await + .map_err(|error| error.to_user_msg()) +} + +/// 资源编辑派生的实现:错误带 typed 分类。 +/// +/// 这一层是「远端原因」与「本地失败」的分界:`ResourceEditError` 只在这里产生, +/// 上层(工具桥)据此决定是原样透传平台原文,还是按本地失败包装。 +pub(crate) async fn derive_local_project_resource_typed( + input: DeriveLocalProjectResourceInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId") + .map_err(ResourceEditError::Other)?; + validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey") + .map_err(ResourceEditError::Other)?; if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { validate_background_removal_options( input.background_mode.as_deref().unwrap_or("complex"), input.screen_color.as_deref(), - )?; + ) + .map_err(ResourceEditError::Other)?; } if input.expected_project_revision > 9_007_199_254_740_991 { - return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + return Err(ResourceEditError::Other( + "expectedProjectRevision 超出 JavaScript 安全整数范围".to_string(), + )); } - let prompt = normalize_resource_edit_prompt(&input.edit_kind, &input.prompt)?; - let asset_name = normalize_resource_edit_name(&input.asset_name)?; + let prompt = normalize_resource_edit_prompt(&input.edit_kind, &input.prompt) + .map_err(ResourceEditError::Other)?; + let asset_name = + normalize_resource_edit_name(&input.asset_name).map_err(ResourceEditError::Other)?; let root = Path::new(input.project_path.trim()); - validate_project_root(root)?; - let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; + validate_project_root(root).map_err(ResourceEditError::Other)?; + let operation_lock = resource_edit_operation_lock(root, &input.operation_id) + .map_err(ResourceEditError::Other)?; let operation_guard = operation_lock.lock_owned().await; - let manifest = read_existing_manifest_for_project(root)?; + let manifest = read_existing_manifest_for_project(root).map_err(ResourceEditError::Other)?; if manifest.project_id != input.expected_project_id { - return Err("project-identity-conflict".to_string()); + return Err(ResourceEditError::Other( + "project-identity-conflict".to_string(), + )); } - let source = resolve_resource_edit_source(root, &manifest, &input)?; + let source = + resolve_resource_edit_source(root, &manifest, &input).map_err(ResourceEditError::Other)?; let request_fingerprint = - resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; + resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name) + .map_err(ResourceEditError::Other)?; let legacy_request_fingerprint = - legacy_resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; + legacy_resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name) + .map_err(ResourceEditError::Other)?; let now = unix_timestamp(); - let existing_ledger = read_resource_edit_ledger(root, &input.operation_id)?; + let existing_ledger = + read_resource_edit_ledger(root, &input.operation_id).map_err(ResourceEditError::Other)?; let new_remote_credentials = if existing_ledger.is_none() && input.edit_kind.is_remote_media() { - Some(resolve_canvas_sync_api_credentials(None, None)?) + Some(resolve_canvas_sync_api_credentials(None, None).map_err(ResourceEditError::Other)?) } else { None }; if existing_ledger.is_none() - && read_game_creator_agent_runtime_project_revision(root)?.revision + && read_game_creator_agent_runtime_project_revision(root) + .map_err(ResourceEditError::Other)? + .revision != input.expected_project_revision { - return Err("project-revision-conflict".to_string()); + return Err(ResourceEditError::Other( + "project-revision-conflict".to_string(), + )); } let mut ledger = match existing_ledger { Some(ledger) => { @@ -5553,7 +5665,8 @@ pub(crate) async fn derive_local_project_resource_at( &prompt, &asset_name, &ledger, - )?; + ) + .map_err(ResourceEditError::Other)?; let fingerprint_matches = matches!( ledger.request_fingerprint.as_str(), value if value == request_fingerprint || value == legacy_request_fingerprint @@ -5564,9 +5677,11 @@ pub(crate) async fn derive_local_project_resource_at( || ledger.idempotency_key != input.idempotency_key || ledger.project_id != input.expected_project_id { - return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string()); + return Err(ResourceEditError::Other( + "operationId 或幂等键已绑定到不同资源编辑请求".to_string(), + )); } - ensure_resource_edit_phase_resumable(&ledger)?; + ensure_resource_edit_phase_resumable(&ledger).map_err(ResourceEditError::Other)?; ledger } None => { @@ -5642,15 +5757,17 @@ pub(crate) async fn derive_local_project_resource_at( if input.edit_kind.is_remote_media() { let (api_base_url, _, platform_session) = new_remote_credentials .as_ref() - .ok_or_else(|| "资源编辑缺少首次访问主体".to_string())?; + .ok_or_else(|| "资源编辑缺少首次访问主体".to_string()) + .map_err(ResourceEditError::Other)?; initialize_resource_edit_access_identity( root, &mut ledger, api_base_url, platform_session.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; } else { - write_resource_edit_ledger(root, &ledger)?; + write_resource_edit_ledger(root, &ledger).map_err(ResourceEditError::Other)?; } ledger } @@ -5658,10 +5775,11 @@ pub(crate) async fn derive_local_project_resource_at( if ledger.phase == ResourceEditLedgerPhase::Committed || input.edit_kind == LocalProjectResourceEditKind::Version { - let project_lock = resource_edit_project_mutation_lock(root)?; + let project_lock = + resource_edit_project_mutation_lock(root).map_err(ResourceEditError::Other)?; let project_guard = project_lock.lock_owned().await; let root = root.to_path_buf(); - return tokio::task::spawn_blocking(move || { + let blocked = tokio::task::spawn_blocking(move || { // 调用方取消等待时,互斥仍由正在运行的提交持有。 let _operation_guard = operation_guard; let _project_guard = project_guard; @@ -5681,14 +5799,17 @@ pub(crate) async fn derive_local_project_resource_at( } }) .await - .map_err(|error| format!("资源编辑提交任务未返回:{error}"))?; + .map_err(|error| ResourceEditError::Other(format!("资源编辑提交任务未返回:{error}")))?; + return blocked.map_err(ResourceEditError::Other); } let remote_credentials = if input.edit_kind.is_remote_media() && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { Some(match new_remote_credentials { Some(credentials) => credentials, - None => resolve_canvas_sync_api_credentials(None, None)?, + None => { + resolve_canvas_sync_api_credentials(None, None).map_err(ResourceEditError::Other)? + } }) } else { None @@ -5702,10 +5823,10 @@ pub(crate) async fn derive_local_project_resource_at( && ledger.platform_owner_user_id.is_some() && frozen_platform_session.is_none() { - return Err( + return Err(ResourceEditError::Other( "authentication-required: 此资源编辑属于已登录账号,请恢复原账号后提交本地结果" .to_string(), - ); + )); } let binding_principal = remote_credentials .as_ref() @@ -5717,28 +5838,36 @@ pub(crate) async fn derive_local_project_resource_at( )?; external_editor_binding_principal(&access) }) - .transpose()?; + .transpose() + .map_err(ResourceEditError::Other)?; if input.edit_kind.is_remote_media() { - bind_resource_edit_platform_owner(&ledger, frozen_platform_session.as_ref())?; + bind_resource_edit_platform_owner(&ledger, frozen_platform_session.as_ref()) + .map_err(ResourceEditError::Other)?; } if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { - let staged = read_optional_resource_edit_staging(root, &input.operation_id)?; + let staged = read_optional_resource_edit_staging(root, &input.operation_id) + .map_err(ResourceEditError::Other)?; let generated = match staged { Some(bytes) => Ok(bytes), - None => generate_resource_edit_text(root, &source, &input, &prompt, &ledger).await, + None => generate_resource_edit_text(root, &source, &input, &prompt, &ledger) + .await + .map_err(ResourceEditError::Other), }; match generated { Err(error) => Err(error), Ok(bytes) => (|| { let content = std::str::from_utf8(&bytes) - .map_err(|_| "派生文本不是 UTF-8".to_string())?; + .map_err(|_| "派生文本不是 UTF-8".to_string()) + .map_err(ResourceEditError::Other)?; let (media_type, extension) = validate_text_derivative( &input.edit_kind, source.source_path.as_deref(), content, - )?; - write_resource_edit_staging(root, &input.operation_id, &bytes)?; + ) + .map_err(ResourceEditError::Other)?; + write_resource_edit_staging(root, &input.operation_id, &bytes) + .map_err(ResourceEditError::Other)?; ledger.staged_media_type = Some(media_type); ledger.staged_extension = Some(extension); update_resource_edit_phase( @@ -5746,17 +5875,20 @@ pub(crate) async fn derive_local_project_resource_at( &mut ledger, ResourceEditLedgerPhase::MediaDownloaded, ) + .map_err(ResourceEditError::Other) })(), } } else if input.edit_kind.is_remote_media() { let (api_base_url, api_key, _) = remote_credentials .as_ref() - .ok_or_else(|| "资源编辑缺少 External Editor 凭据".to_string())?; + .ok_or_else(|| "资源编辑缺少 External Editor 凭据".to_string()) + .map_err(ResourceEditError::Other)?; let access = ExternalEditorBindingAccess::new( api_base_url, api_key, frozen_platform_session.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; prepare_remote_resource_edit( root, &input, @@ -5767,27 +5899,36 @@ pub(crate) async fn derive_local_project_resource_at( &access, binding_principal .as_ref() - .ok_or_else(|| "资源编辑缺少 External Editor principal".to_string())?, + .ok_or_else(|| "资源编辑缺少 External Editor principal".to_string()) + .map_err(ResourceEditError::Other)?, ) .await } else { - Err("当前资源类型没有编辑实现".to_string()) + Err(ResourceEditError::Other( + "当前资源类型没有编辑实现".to_string(), + )) }; if let Err(error) = generation_result { + // TODO refactor string-typed:`result-unknown` 还没 typed 化,先在未分类分支里按前缀判。 if ledger.remote_operation_id.is_none() && ledger.phase != ResourceEditLedgerPhase::RemoteCompleted - && error.contains("result-unknown") + && matches!( + &error, + ResourceEditError::Other(message) if message.contains("result-unknown") + ) { update_resource_edit_phase( root, &mut ledger, ResourceEditLedgerPhase::ReconciliationRequired, - )?; + ) + .map_err(ResourceEditError::Other)?; } return Err(error); } } - let project_lock = resource_edit_project_mutation_lock(root)?; + let project_lock = + resource_edit_project_mutation_lock(root).map_err(ResourceEditError::Other)?; let project_guard = project_lock.lock_owned().await; let commit_root = root.to_path_buf(); let commit_input = input.clone(); @@ -5809,7 +5950,8 @@ pub(crate) async fn derive_local_project_resource_at( Ok::<_, String>((result, ledger, operation_guard, project_guard)) }) .await - .map_err(|error| format!("资源编辑提交任务未返回:{error}"))??; + .map_err(|error| ResourceEditError::Other(format!("资源编辑提交任务未返回:{error}")))? + .map_err(ResourceEditError::Other)?; if let (Some((api_base_url, api_key, _)), Some(principal)) = (remote_credentials.as_ref(), binding_principal.as_ref()) { @@ -5817,7 +5959,8 @@ pub(crate) async fn derive_local_project_resource_at( api_base_url, api_key, frozen_platform_session.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; // The local asset transaction is already committed. A binding-sidecar failure must not // roll back or hide that durable local result; a future edit will safely re-upload bytes. let _ = @@ -7701,7 +7844,8 @@ mod tests { .build() .unwrap(); submit_and_persist_resource_edit_remote(root, &client, &access, &mut ledger) - .await?; + .await + .map_err(ResourceEditError::Other)?; assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); assert_eq!( ledger.remote_operation_id.as_deref(), @@ -7907,6 +8051,7 @@ mod tests { }) .await .expect_err("poll authentication status must fail"); + let error = error.to_user_msg(); server.join().expect("join poll auth fixture"); let expected_code = if status.starts_with("401") { "authentication-required" @@ -7940,6 +8085,7 @@ mod tests { }) .await .expect_err("missing remote status must stay unknown"); + let error = error.to_user_msg(); server.join().expect("join missing status fixture"); assert!(error.contains("result-unknown")); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); @@ -8541,7 +8687,7 @@ mod tests { ( "400 Bad Request", ResourceEditLedgerPhase::RemoteFailed, - "remote-terminal-failed", + "资源编辑请求被拒绝", ), ( "502 Bad Gateway", @@ -8590,6 +8736,12 @@ mod tests { .expect("submission status must fail"); server.join().expect("join submission fixture"); assert!(error.contains(expected_error), "{status}: {error}"); + if status.starts_with("400") { + assert!( + error.contains("provider detail must not persist"), + "平台拒绝原因必须带出到文案:{error}" + ); + } let persisted = read_resource_edit_ledger(root, &request.operation_id) .expect("read submission ledger") @@ -8927,8 +9079,13 @@ mod tests { }) .await .expect_err("failed status must become terminal"); + let error = error.to_user_msg(); server.join().expect("join failed status fixture"); - assert!(error.contains("remote-terminal-failed")); + assert!( + error.contains("secret provider detail"), + "平台原文必须带出到文案:{error}" + ); + assert!(!error.contains("remote-terminal-failed"), "{error}"); let persisted = read_resource_edit_ledger(root, &request.operation_id) .expect("read terminal ledger") .expect("terminal ledger"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index 3428ad8cb..3f1c306cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -702,12 +702,19 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() { let error = with_test_credentials( &base_url, - derive_local_project_resource_at(fixture.request.clone()), + derive_local_project_resource_typed(fixture.request.clone()), ) .await .expect_err("remote failure must be returned"); server.join().expect("join failure server"); - assert!(error.contains("remote-terminal-failed")); + // 平台没给 `error` 原文时走兜底文案(带原文的路径见 resource_editor.rs 的远端终态测试)。 + assert_eq!( + error, + ResourceEditError::RemoteGenerationFailed { + server_message: None + } + ); + assert_eq!(error.to_user_msg(), "资源编辑生成失败"); let manifest = read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); assert_eq!(manifest.assets.len(), 1); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs new file mode 100644 index 000000000..ce7b61668 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -0,0 +1,37 @@ +//! `resource_editor` 的错误定义。 +//! +//! 这一层整体还是 `Result<_, String>`(见 `Other` 上的 TODO),这里先把**远端资源编辑终态 +//! 失败**这条穿出来:平台给的原因必须作为数据往上走,而不是在产生点就拼成一句带前缀的话—— +//! 否则工具层再也分不出「远端失败」和「本地失败」,只能把两句前缀叠在一起。 + +/// 资源编辑失败。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ResourceEditError { + /// 平台轮询到终态 `status=failed`。`server_message` 是平台 `error` 字段原文,平台没给时为空。 + /// + /// 原文只进当次错误文案,不进账本(账本只存 `terminal_failure_code` 分类码)。 + RemoteGenerationFailed { server_message: Option }, + /// 尚未分类的失败:`message` 是产生点写好的、可直接展示的文案或稳定码。 + // TODO refactor string-typed + Other(String), +} + +impl ResourceEditError { + /// 面向用户与模型的文案。只有这一层知道「远端生成失败」该怎么说, + /// 上层(工具层)原样透传,不再叠自己的前缀。 + pub(crate) fn to_user_msg(&self) -> String { + match self { + Self::RemoteGenerationFailed { server_message } => match server_message { + Some(server_message) => format!("资源编辑生成失败:{server_message}"), + None => "资源编辑生成失败".to_string(), + }, + Self::Other(message) => message.clone(), + } + } +} + +impl std::fmt::Display for ResourceEditError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.to_user_msg()) + } +}