From 44d362c587e98881bbe34117b22b2421ef9d8fbb Mon Sep 17 00:00:00 2001 From: menghao Date: Mon, 10 Aug 2026 17:17:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E5=85=A8=E7=B1=BB=E5=9E=8B=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E8=87=B3External=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除WebView登录Token读取及Tauri命令透传 统一图片视频音效和背景音乐的External v1调用链 严格校验202受理语义并保护历史站内账本 补齐凭据隔离非破坏性派生和视频端到端测试 同步客户端创作合同与项目共享记忆 --- .../src-tauri/src/assets.rs | 34 -- .../src/project/asset_canvas/generation.rs | 484 ++++-------------- .../src-tauri/src/project/resource_editor.rs | 441 +++++++++++++--- .../tauriImageCanvasHostAdapter.ts | 14 +- .../src/view/project-development/index.tsx | 10 - .../project-development/resourceEditModel.ts | 8 - .../tests/assetCanvasSurface.test.tsx | 32 +- .../projectResourceLiveIntegration.test.tsx | 4 + .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 16 +- 12 files changed, 538 insertions(+), 525 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 2eca8729c..1206f9714 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -462,40 +462,6 @@ pub(crate) async fn resolve_canvas_resource_download( .await } -pub(crate) async fn resolve_authenticated_canvas_resource_download( - client: &reqwest::Client, - api_base_url: &str, - access_token: &str, - resource: &serde_json::Value, -) -> Result, String> { - resolve_authenticated_canvas_resource_download_with_limit( - client, - api_base_url, - access_token, - resource, - 20 * 1024 * 1024, - ) - .await -} - -pub(crate) async fn resolve_authenticated_canvas_resource_download_with_limit( - client: &reqwest::Client, - api_base_url: &str, - access_token: &str, - resource: &serde_json::Value, - max_bytes: usize, -) -> Result, String> { - resolve_canvas_resource_download_with_limit_and_route( - client, - api_base_url, - access_token, - resource, - max_bytes, - "/api/assets/read-url", - ) - .await -} - fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool { let Ok(api_base_url) = url::Url::parse(api_base_url) else { return false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 8ba710c65..8679d13ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -35,8 +35,6 @@ pub(crate) struct GenerateAssetCanvasImageInput { pub(crate) asset_kind: String, pub(crate) asset_name: String, pub(crate) reference_resource_ids: Vec, - #[serde(default)] - pub(crate) access_token: Option, } #[derive(Clone, Deserialize)] @@ -45,8 +43,6 @@ pub(crate) struct RecoverAssetCanvasGenerationsInput { pub(crate) project_path: String, pub(crate) expected_project_id: String, pub(crate) draft_id: String, - #[serde(default)] - pub(crate) access_token: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -291,67 +287,24 @@ impl From for ReferencePreparationError { } } -enum CanvasGenerationApiMode { - Authenticated { access_token: String }, - ExternalDeveloper { api_key: String }, +struct CanvasGenerationApiMode { + api_key: String, } impl CanvasGenerationApiMode { fn bearer_token(&self) -> &str { - match self { - Self::Authenticated { access_token } => access_token, - Self::ExternalDeveloper { api_key } => api_key, - } - } - - fn is_authenticated(&self) -> bool { - matches!(self, Self::Authenticated { .. }) - } - - fn endpoint(&self, authenticated: &'static str, external: &'static str) -> &'static str { - if self.is_authenticated() { - authenticated - } else { - external - } + &self.api_key } } -fn resolve_generation_api_mode( - access_token: Option<&str>, -) -> Result<(String, CanvasGenerationApiMode), String> { - if let Some(access_token) = access_token - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if access_token.len() > 16 * 1024 || access_token.chars().any(char::is_control) { - return Err("authentication-required: 登录凭据无效,请重新登录".to_string()); - } - return Ok(( - DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(), - CanvasGenerationApiMode::Authenticated { - access_token: access_token.to_string(), - }, - )); - } - +fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), String> { let api_base_url = resolve_canvas_sync_api_base_url(None)?; let api_key = resolve_canvas_sync_api_key(None)?; - Ok(( - api_base_url, - CanvasGenerationApiMode::ExternalDeveloper { api_key }, - )) + Ok((api_base_url, CanvasGenerationApiMode { api_key })) } fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { - if mode.is_authenticated() { - asset_canvas_sha256(format!("authenticated:{}", api_base_url.trim()).as_bytes()) - } else { - platform_art_generation_external_configuration_fingerprint( - api_base_url, - mode.bearer_token(), - ) - } + platform_art_generation_external_configuration_fingerprint(api_base_url, mode.bearer_token()) } fn authorize_canvas_request( @@ -964,10 +917,7 @@ async fn try_confirm_uploaded_reference( material: &ReferenceMaterial, asset_kind: &str, ) -> Result, ReferencePreparationError> { - let endpoint = api_mode.endpoint( - "/api/assets/objects/confirm", - "/api/external/v1/assets/objects/confirm", - ); + let endpoint = "/api/external/v1/assets/objects/confirm"; let response = authorize_canvas_request( client .post(format!("{api_base_url}{endpoint}")) @@ -1019,10 +969,7 @@ async fn request_upload_ticket( ledger: &AssetCanvasGenerationLedger, material: &ReferenceMaterial, ) -> Result { - let endpoint = api_mode.endpoint( - "/api/assets/direct-upload-tickets", - "/api/external/v1/assets/direct-upload-tickets", - ); + let endpoint = "/api/external/v1/assets/direct-upload-tickets"; let payload = external_editor_json_request( authorize_canvas_request( client @@ -1260,106 +1207,12 @@ async fn prepare_canvas_generation_context( api_base_url: &str, api_mode: &CanvasGenerationApiMode, ) -> Result { - if !api_mode.is_authenticated() { - return prepare_external_canvas_generation_context( - root, - client, - api_base_url, - api_mode.bearer_token(), - ) - .await; - } - - let manifest = read_existing_manifest_for_project(root)?; - let canvas_name = manifest.name.trim().chars().take(80).collect::(); - let canvas_name = if canvas_name.is_empty() { - "未命名游戏原型".to_string() - } else { - canvas_name - }; - let projects_payload = external_editor_json_request( - authorize_canvas_request( - client.get(format!("{api_base_url}/api/editor/projects")), - api_mode, - ), - "读取画布项目", - ) - .await?; - let projects = external_editor_response_data(&projects_payload) - .get("projects") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "画布项目响应缺少 projects".to_string())?; - let project_id = projects - .iter() - .find(|project| json_string_field(project, "title").as_deref() == Some(&canvas_name)) - .and_then(|project| json_string_field(project, "projectId")); - let project_id = match project_id { - Some(project_id) => project_id, - None => { - let payload = external_editor_json_request( - authorize_canvas_request( - client - .post(format!("{api_base_url}/api/editor/projects")) - .json(&serde_json::json!({ "title": canvas_name })), - api_mode, - ), - "创建画布项目", - ) - .await?; - external_editor_response_data(&payload) - .get("project") - .and_then(|project| json_string_field(project, "projectId")) - .ok_or_else(|| "创建画布项目响应缺少 projectId".to_string())? - } - }; - - let library_payload = external_editor_json_request( - authorize_canvas_request( - client.get(format!("{api_base_url}/api/editor/assets/library")), - api_mode, - ), - "读取画布素材库", - ) - .await?; - let folders = external_editor_response_data(&library_payload) - .get("library") - .and_then(|library| library.get("folders")) - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "画布素材库响应缺少 library.folders".to_string())?; - let asset_folder_id = folders - .iter() - .find(|folder| json_string_field(folder, "label").as_deref() == Some(&canvas_name)) - .and_then(|folder| json_string_field(folder, "folderId")); - let asset_folder_id = match asset_folder_id { - Some(folder_id) => folder_id, - None => { - let payload = external_editor_json_request( - authorize_canvas_request( - client - .post(format!("{api_base_url}/api/editor/assets/folders")) - .json(&serde_json::json!({ "label": canvas_name })), - api_mode, - ), - "创建画布素材目录", - ) - .await?; - external_editor_response_data(&payload) - .get("folder") - .and_then(|folder| json_string_field(folder, "folderId")) - .ok_or_else(|| "创建画布素材目录响应缺少 folderId".to_string())? - } - }; - - Ok(ExternalCanvasGenerationContext { - project_id, - asset_folder_id, - canvas_name, - }) + prepare_external_canvas_generation_context(root, client, api_base_url, api_mode.bearer_token()) + .await } fn build_generation_request_snapshot( ledger: &AssetCanvasGenerationLedger, - api_mode: &CanvasGenerationApiMode, ) -> Result<(String, String), String> { let context = ledger .canvas_context @@ -1444,10 +1297,7 @@ fn build_generation_request_snapshot( "referenceImageSrcs".to_string(), serde_json::json!(additional), ); - api_mode.endpoint( - "/api/editor/images/edits", - "/api/external/v1/editor/images/edits", - ) + "/api/external/v1/editor/images/edits" } else { body.insert( "referenceImageSrcs".to_string(), @@ -1456,16 +1306,20 @@ fn build_generation_request_snapshot( if let Some(kind) = external_generation_kind(&ledger.asset_kind) { body.insert("kind".to_string(), serde_json::json!(kind)); } - api_mode.endpoint( - "/api/editor/images/generations", - "/api/external/v1/editor/images/generations", - ) + "/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()); @@ -1570,8 +1424,10 @@ fn mark_generation_error( fn sanitized_generation_error(code: &str) -> String { match code { - "configuration-missing" => "图片生成配置缺失,未创建任何资源".to_string(), - "authentication-required" => "登录已失效,请重新登录后重试".to_string(), + "configuration-missing" => "External Editor API 配置缺失,未创建任何资源".to_string(), + "authentication-required" => { + "External Editor API Key 无效或权限不足,请检查客户端私有配置".to_string() + } "insufficient-mud-points" => "泥点余额不足,请充值后重试".to_string(), "platform-service-configuration" => { "平台图片生成服务暂不可用,请稍后重试或联系管理员".to_string() @@ -1597,8 +1453,7 @@ fn sanitized_generation_error(code: &str) -> String { fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String { if reconciliation && code == "authentication-required" { - return "reconciliation-required: 登录已失效;请重新登录后使用原 operation 恢复" - .to_string(); + return "reconciliation-required: External Editor API Key 无效或权限不足;修正私有配置后使用原 operation 恢复".to_string(); } sanitized_generation_error(code) } @@ -1613,36 +1468,21 @@ fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool } enum CanvasGenerationInitialResponse { - Completed(serde_json::Value), Async(serde_json::Value), } fn classify_canvas_generation_initial_response( status: reqwest::StatusCode, payload: &serde_json::Value, - api_mode: &CanvasGenerationApiMode, ) -> Result { - if !api_mode.is_authenticated() { - return match classify_external_generation_initial_response(status, payload)? { - ExternalGenerationInitialResponse::LegacyCompleted(result) => { - Ok(CanvasGenerationInitialResponse::Completed(result)) - } - ExternalGenerationInitialResponse::AsyncSubmission(submission) => { - Ok(CanvasGenerationInitialResponse::Async(submission)) - } - }; - } - - let data = external_editor_response_data(payload); - if let Some(queue_state) = data.get("queueState").filter(|value| value.is_object()) { - if json_string_field(queue_state, "operationId").is_some() { - return Ok(CanvasGenerationInitialResponse::Async(queue_state.clone())); + 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()) } } - if external_generation_result_has_download_reference(data) { - return Ok(CanvasGenerationInitialResponse::Completed(data.clone())); - } - Err("登录态图片生成响应既没有 operationId,也没有稳定结果引用".to_string()) } async fn wait_for_canvas_generation_result( @@ -1651,123 +1491,28 @@ async fn wait_for_canvas_generation_result( api_mode: &CanvasGenerationApiMode, submission_payload: &serde_json::Value, ) -> Result { - if !api_mode.is_authenticated() { - return wait_for_external_generation_result( - client, - api_base_url, - api_mode.bearer_token(), - submission_payload, - ) - .await; - } - - let submission = external_editor_response_data(submission_payload); - let operation_id = json_string_field(submission, "operationId") - .ok_or_else(|| "图片生成提交响应缺少 operationId".to_string())?; - let operation_id_path = - url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); - let status_url = - format!("{api_base_url}/api/runtime/external-generation/jobs/{operation_id_path}"); - let started_at = tokio::time::Instant::now(); - let mut poll_after_ms = external_generation_poll_after_ms(submission_payload); - - loop { - if started_at.elapsed() >= Duration::from_secs(35 * 60) { - return Err(format!( - "result-unknown: 平台图片生成任务仍在执行,已停止本地等待;operationId={operation_id}" - )); - } - if poll_after_ms > 0 { - tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; - } - let response = authorize_canvas_request(client.get(&status_url), api_mode) - .send() - .await - .map_err(|_| { - format!("result-unknown: 查询平台图片生成任务失败;operationId={operation_id}") - })?; - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: 登录已失效,请重新登录".to_string()); - } - if [429, 502, 503, 504].contains(&status.as_u16()) { - poll_after_ms = 2_000; - continue; - } - if !status.is_success() { - return Err(format!( - "result-unknown: 查询平台图片生成任务返回 HTTP {};operationId={operation_id}", - status.as_u16() - )); - } - let payload = response.json::().await.map_err(|_| { - format!("result-unknown: 解析平台图片生成任务响应失败;operationId={operation_id}") - })?; - let data = external_editor_response_data(&payload); - let generation = data - .get("job") - .filter(|value| value.is_object()) - .unwrap_or(data); - match json_string_field(generation, "status").as_deref() { - Some("completed") => { - let result = generation - .get("result") - .filter(|result| !result.is_null()) - .cloned() - .ok_or_else(|| { - format!( - "result-unknown: 平台图片生成任务已完成但响应缺少 result;operationId={operation_id}" - ) - })?; - if !external_generation_result_has_download_reference(&result) { - return Err(format!( - "result-unknown: 平台图片生成任务完成结果缺少可下载媒体;operationId={operation_id}" - )); - } - return Ok(result); - } - Some("failed") => { - let error = json_string_field(generation, "error") - .or_else(|| json_string_field(generation, "phaseDetail")) - .unwrap_or_else(|| "生成任务失败".to_string()); - return Err(format!( - "平台图片生成任务失败:{error};operationId={operation_id}" - )); - } - Some("queued" | "running") => { - poll_after_ms = external_generation_poll_after_ms(generation); - } - Some(_) | None => { - return Err(format!( - "result-unknown: 平台图片生成任务状态无效;operationId={operation_id}" - )); - } - } - } + wait_for_external_generation_result( + client, + api_base_url, + api_mode.bearer_token(), + submission_payload, + ) + .await } -async fn classify_canvas_submit_error( - response: reqwest::Response, - api_mode: &CanvasGenerationApiMode, -) -> (&'static str, bool) { +async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static str, bool) { let status = response.status(); - if api_mode.is_authenticated() - && matches!( - status, - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN - ) - { + if matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { return ("authentication-required", false); } let body = response.text().await.unwrap_or_default(); - if api_mode.is_authenticated() - && status == reqwest::StatusCode::CONFLICT - && body.contains("泥点余额不足") - { + if status == reqwest::StatusCode::CONFLICT && body.contains("泥点余额不足") { return ("insufficient-mud-points", false); } - if api_mode.is_authenticated() - && status.is_server_error() + if status.is_server_error() && ["配置", "API Key", "供应商", "provider"] .iter() .any(|marker| body.contains(marker)) @@ -1833,6 +1578,17 @@ async fn reconcile_generation( mark_generation_error(root, &mut ledger, true, "configuration-changed", emit)?; return Err(sanitized_generation_error("configuration-changed")); } + 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 = reqwest::Client::builder() .timeout(Duration::from_secs(60)) .build() @@ -1861,9 +1617,7 @@ async fn reconcile_generation( { Ok(context) => context, Err(error) => { - let code = if api_mode.is_authenticated() - && (error.contains("HTTP 401") || error.contains("HTTP 403")) - { + let code = if error.contains("HTTP 401") || error.contains("HTTP 403") { "authentication-required" } else { "platform-service-configuration" @@ -1882,7 +1636,7 @@ async fn reconcile_generation( mark_generation_error(root, &mut ledger, false, code, emit)?; return Err(sanitized_generation_error(code)); } - let (endpoint, body_json) = build_generation_request_snapshot(&ledger, api_mode)?; + 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); @@ -1920,7 +1674,7 @@ async fn reconcile_generation( }; let status = response.status(); if !status.is_success() { - let (code, reconciliation) = classify_canvas_submit_error(response, api_mode).await; + let (code, reconciliation) = classify_canvas_submit_error(response).await; let reconciliation = preserve_submit_reconciliation(&ledger.phase, code, reconciliation); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; @@ -1933,12 +1687,7 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; - match classify_canvas_generation_initial_response(status, &submission, api_mode) { - Ok(CanvasGenerationInitialResponse::Completed(generated)) => { - set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; - publish_public_phase(root, &mut ledger, emit)?; - generated - } + match classify_canvas_generation_initial_response(status, &submission) { Ok(CanvasGenerationInitialResponse::Async(submission)) => { let operation_id = json_string_field(external_editor_response_data(&submission), "operationId") @@ -2030,23 +1779,13 @@ async fn reconcile_generation( .remote_result .as_ref() .ok_or_else(|| "远端完成账本缺少稳定引用".to_string())?; - let download_result = if api_mode.is_authenticated() { - resolve_authenticated_canvas_resource_download( - &client, - api_base_url, - api_mode.bearer_token(), - &serde_json::json!({ "objectKey": remote.object_key }), - ) - .await - } else { - resolve_canvas_resource_download( - &client, - api_base_url, - api_mode.bearer_token(), - &serde_json::json!({ "objectKey": remote.object_key }), - ) - .await - }; + let download_result = resolve_canvas_resource_download( + &client, + api_base_url, + api_mode.bearer_token(), + &serde_json::json!({ "objectKey": remote.object_key }), + ) + .await; let download = match download_result { Ok(Some(download)) => download, _ => { @@ -2277,15 +2016,10 @@ pub(crate) async fn generate_asset_canvas_image_at( let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; let mut ledger = validate_and_prepare_ledger(root, input)?; - let (api_base_url, api_mode) = match resolve_generation_api_mode(input.access_token.as_deref()) - { + let (api_base_url, api_mode) = match resolve_generation_api_mode() { Ok(value) => value, Err(_) => { - let code = if input.access_token.is_some() { - "authentication-required" - } else { - "configuration-missing" - }; + let code = "configuration-missing"; mark_generation_error(root, &mut ledger, false, code, &mut emit)?; return Err(sanitized_generation_error(code)); } @@ -2339,14 +2073,8 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } - let (api_base_url, api_mode) = resolve_generation_api_mode(input.access_token.as_deref()) - .map_err(|_| { - if input.access_token.is_some() { - sanitized_generation_error("authentication-required") - } else { - sanitized_generation_error("configuration-missing") - } - })?; + let (api_base_url, api_mode) = resolve_generation_api_mode() + .map_err(|_| sanitized_generation_error("configuration-missing"))?; let mut resumed = Vec::new(); let mut events = Vec::new(); for generation_id in generation_ids { @@ -2561,7 +2289,6 @@ mod tests { asset_kind: "illustration".to_string(), asset_name: "阶段五生成图".to_string(), reference_resource_ids: Vec::new(), - access_token: None, } } @@ -3245,7 +2972,6 @@ mod tests { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), - access_token: None, }; let first = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) .await @@ -3363,11 +3089,8 @@ mod tests { created_at: now, updated_at: now, }; - let api_mode = CanvasGenerationApiMode::ExternalDeveloper { - api_key: "test-api-key".to_string(), - }; let (endpoint, request) = - build_generation_request_snapshot(&ledger, &api_mode).expect("build refine 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"); @@ -3518,53 +3241,67 @@ mod tests { } #[test] - fn authenticated_access_token_is_ephemeral_and_never_enters_generation_ledger() { - let project_id = "phase-five-authenticated-token"; - let (directory, draft) = create_generation_fixture(project_id, "阶段五登录态凭据测试"); - let secret = "session-token-must-never-be-persisted"; - let mut input = generation_input( + 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 _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": "http://127.0.0.1:9", + "apiKey": secret + } + }) + .to_string(), + ); + let input = generation_input( directory.path(), project_id, &draft, - "登录态凭据不能进入私有账本", + "External 凭据不能进入私有账本", ); - input.access_token = Some(secret.to_string()); let mut ledger = validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); let (api_base_url, api_mode) = - resolve_generation_api_mode(input.access_token.as_deref()).expect("resolve auth mode"); - assert!(api_mode.is_authenticated()); + resolve_generation_api_mode().expect("resolve external mode"); assert_eq!(api_mode.bearer_token(), secret); - assert_eq!( - api_mode.endpoint("/api/assets/read-url", "/api/external/v1/assets/read-url"), - "/api/assets/read-url" - ); ledger.canvas_context = Some(PrivateCanvasContext { - project_id: "authenticated-project".to_string(), - asset_folder_id: "authenticated-folder".to_string(), - canvas_name: "阶段五登录态凭据测试".to_string(), + 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, &api_mode).expect("build auth request"); - assert_eq!(endpoint, "/api/editor/images/generations"); + build_generation_request_snapshot(&ledger).expect("build external request"); + assert_eq!(endpoint, "/api/external/v1/editor/images/generations"); + 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::OK, + reqwest::StatusCode::ACCEPTED, &serde_json::json!({ "data": { - "queueState": { - "operationId": "authenticated-operation", - "status": "queued" - } + "operationId": "external-operation", + "status": "queued" } }), - &api_mode, ) - .expect("classify authenticated queue response"), + .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_fingerprint = Some(canvas_api_identity_fingerprint(&api_base_url, &api_mode)); write_generation_ledger(directory.path(), &mut ledger).expect("persist private ledger"); @@ -3577,6 +3314,7 @@ mod tests { .expect("read private generation ledger"); assert!(!persisted.contains(secret)); assert!(!persisted.contains("accessToken")); + assert!(!persisted.contains("apiKey")); assert!(persisted.contains("apiIdentityFingerprint")); } 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 8b57131c7..5b74eb5e5 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 @@ -80,8 +80,6 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) source_version_id: Option, pub(crate) prompt: String, pub(crate) asset_name: String, - #[serde(default)] - pub(crate) access_token: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -157,6 +155,8 @@ struct ResourceEditLedger { source_sha256: String, prompt: String, asset_name: String, + #[serde(default)] + api_identity_fingerprint: Option, phase: ResourceEditLedgerPhase, endpoint: Option, request_body_json: Option, @@ -750,23 +750,10 @@ fn read_optional_resource_edit_staging( } } -fn normalized_access_token(input: &DeriveLocalProjectResourceInput) -> Result { - let token = input - .access_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "authentication-required: 登录已失效,请重新登录".to_string())?; - if token.len() > 16 * 1024 || token.chars().any(char::is_control) { - return Err("authentication-required: 登录凭据无效,请重新登录".to_string()); - } - Ok(token.to_string()) -} - async fn request_resource_edit_upload_ticket( client: &reqwest::Client, api_base_url: &str, - access_token: &str, + api_key: &str, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, ) -> Result { @@ -782,8 +769,10 @@ async fn request_resource_edit_upload_ticket( .map(sanitize_file_name) .unwrap_or_else(|| "source.bin".to_string()); let response = client - .post(format!("{api_base_url}/api/assets/direct-upload-tickets")) - .bearer_auth(access_token) + .post(format!( + "{api_base_url}/api/external/v1/assets/direct-upload-tickets" + )) + .bearer_auth(api_key) .json(&resource_edit_upload_ticket_payload( input, source, @@ -796,7 +785,7 @@ async fn request_resource_edit_upload_ticket( if response.status() == reqwest::StatusCode::UNAUTHORIZED || response.status() == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: 登录已失效,请重新登录".to_string()); + return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); } if !response.status().is_success() { return Err(format!( @@ -915,7 +904,7 @@ async fn upload_resource_edit_source( async fn confirm_resource_edit_source( client: &reqwest::Client, api_base_url: &str, - access_token: &str, + api_key: &str, ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, ) -> Result { @@ -924,8 +913,10 @@ async fn confirm_resource_edit_source( .as_ref() .ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?; let response = client - .post(format!("{api_base_url}/api/assets/objects/confirm")) - .bearer_auth(access_token) + .post(format!( + "{api_base_url}/api/external/v1/assets/objects/confirm" + )) + .bearer_auth(api_key) .json(&serde_json::json!({ "bucket": ticket.bucket, "objectKey": ticket.object_key, @@ -941,7 +932,7 @@ async fn confirm_resource_edit_source( if response.status() == reqwest::StatusCode::UNAUTHORIZED || response.status() == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: 登录已失效,请重新登录".to_string()); + return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); } if !response.status().is_success() { return Err(format!( @@ -968,7 +959,7 @@ async fn ensure_resource_edit_source_reference( root: &Path, client: &reqwest::Client, api_base_url: &str, - access_token: &str, + api_key: &str, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, ledger: &mut ResourceEditLedger, @@ -996,11 +987,10 @@ async fn ensure_resource_edit_source_reference( } } let ticket = - request_resource_edit_upload_ticket(client, api_base_url, access_token, input, source) - .await?; + request_resource_edit_upload_ticket(client, api_base_url, api_key, input, source).await?; upload_resource_edit_source(&ticket, source, api_base_url).await?; let _asset_object_id = - confirm_resource_edit_source(client, api_base_url, access_token, &ticket, source).await?; + confirm_resource_edit_source(client, api_base_url, api_key, &ticket, source).await?; ledger.source_stable_reference = Some(ticket.object_key.clone()); write_resource_edit_ledger(root, ledger)?; Ok(ticket.object_key) @@ -1062,7 +1052,7 @@ fn resource_edit_remote_request( }); match input.edit_kind { LocalProjectResourceEditKind::ImageReference => Ok(( - "/api/editor/images/edits", + "/api/external/v1/editor/images/edits", serde_json::json!({ "prompt": prompt, "sourceImageSrc": source_reference.ok_or_else(|| "图片编辑缺少稳定源引用".to_string())?, @@ -1072,7 +1062,7 @@ fn resource_edit_remote_request( }), )), LocalProjectResourceEditKind::Video => Ok(( - "/api/editor/videos/generations", + "/api/external/v1/editor/videos/generations", serde_json::json!({ "prompt": prompt, "model": "seedance2.0-fast", @@ -1089,7 +1079,7 @@ fn resource_edit_remote_request( }), )), LocalProjectResourceEditKind::SoundEffect => Ok(( - "/api/editor/audios/sound-effects/generations", + "/api/external/v1/editor/audios/sound-effects/generations", serde_json::json!({ "prompt": build_resource_edit_audio_prompt(source, prompt, 2_048)?, "model": "eleven_text_to_sound_v2", @@ -1099,7 +1089,7 @@ fn resource_edit_remote_request( }), )), LocalProjectResourceEditKind::BackgroundMusic => Ok(( - "/api/editor/audios/background-music/generations", + "/api/external/v1/editor/audios/background-music/generations", serde_json::json!({ "gptDescriptionPrompt": build_resource_edit_audio_prompt(source, prompt, 200)?, "makeInstrumental": true, @@ -1129,23 +1119,38 @@ fn resource_edit_result_has_download(payload: &serde_json::Value) -> bool { || json_string_field(data, "objectKey").is_some() } +fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { + matches!( + endpoint, + "/api/external/v1/editor/images/edits" + | "/api/external/v1/editor/videos/generations" + | "/api/external/v1/editor/audios/sound-effects/generations" + | "/api/external/v1/editor/audios/background-music/generations" + ) +} + async fn submit_resource_edit_remote( client: &reqwest::Client, api_base_url: &str, - access_token: &str, + api_key: &str, ledger: &ResourceEditLedger, ) -> Result { let endpoint = ledger .endpoint .as_deref() .ok_or_else(|| "资源编辑账本缺少 endpoint".to_string())?; + if !is_external_resource_edit_endpoint(endpoint) { + return Err( + "result-unknown: 历史站内资源编辑 endpoint 不能由 External v1 自动重放".to_string(), + ); + } let body = ledger .request_body_json .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; let response = client .post(format!("{api_base_url}{endpoint}")) - .bearer_auth(access_token) + .bearer_auth(api_key) .header("Idempotency-Key", &ledger.idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") .body(body.to_string()) @@ -1154,21 +1159,23 @@ async fn submit_resource_edit_remote( .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: 登录已失效,请重新登录".to_string()); + return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); } - if !status.is_success() { + if status != reqwest::StatusCode::ACCEPTED { + if status.is_success() { + return Err(format!( + "result-unknown: External v1 资源编辑必须返回 HTTP 202,实际为 HTTP {}", + status.as_u16() + )); + } return Err(format!("资源编辑生成失败:HTTP {}", status.as_u16())); } let payload = response .json::() .await .map_err(|_| "result-unknown: 资源编辑响应无法解析".to_string())?; - if status == reqwest::StatusCode::ACCEPTED { - if resource_edit_operation_id(&payload).is_none() { - return Err("result-unknown: 资源编辑已受理但响应缺少 operationId".to_string()); - } - } else if !resource_edit_result_has_download(&payload) { - return Err("资源编辑同步响应缺少可下载结果".to_string()); + if resource_edit_operation_id(&payload).is_none() { + return Err("result-unknown: 资源编辑已受理但响应缺少 operationId".to_string()); } Ok(payload) } @@ -1176,12 +1183,12 @@ async fn submit_resource_edit_remote( async fn wait_for_resource_edit_remote( client: &reqwest::Client, api_base_url: &str, - access_token: &str, + api_key: &str, operation_id: &str, ) -> Result { let operation_id = url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); - let status_url = format!("{api_base_url}/api/runtime/external-generation/jobs/{operation_id}"); + let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id}"); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = 1_000; loop { @@ -1191,14 +1198,16 @@ async fn wait_for_resource_edit_remote( tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; let response = client .get(&status_url) - .bearer_auth(access_token) + .bearer_auth(api_key) .send() .await .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; if response.status() == reqwest::StatusCode::UNAUTHORIZED || response.status() == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: 登录已失效,请重新登录".to_string()); + return Err( + "authentication-required: External Editor API Key 无效或权限不足".to_string(), + ); } if [429, 502, 503, 504].contains(&response.status().as_u16()) { poll_after_ms = 2_000; @@ -1232,8 +1241,7 @@ async fn wait_for_resource_edit_remote( return Ok(result); } Some("failed") => { - return Err(json_string_field(job, "error") - .unwrap_or_else(|| "资源编辑生成失败".to_string())); + return Err("资源编辑生成失败".to_string()); } Some("queued" | "running") => { poll_after_ms = external_generation_poll_after_ms(job); @@ -1342,8 +1350,34 @@ async fn prepare_remote_resource_edit( asset_name: &str, ledger: &mut ResourceEditLedger, ) -> Result<(), String> { - let access_token = normalized_access_token(input)?; - let api_base_url = DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(); + let api_base_url = resolve_canvas_sync_api_base_url(None) + .map_err(|_| "External Editor API base URL 配置无效".to_string())?; + let api_key = resolve_canvas_sync_api_key(None) + .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let api_identity_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + if ledger + .api_identity_fingerprint + .as_ref() + .is_some_and(|value| value != &api_identity_fingerprint) + { + return Err( + "result-unknown: External Editor 配置已变更,必须保留原 operation 对账".to_string(), + ); + } + if ledger + .endpoint + .as_deref() + .is_some_and(|endpoint| !is_external_resource_edit_endpoint(endpoint)) + { + return Err( + "result-unknown: 历史站内资源编辑 operation 不能由 External v1 自动重放".to_string(), + ); + } + if ledger.api_identity_fingerprint.is_none() { + ledger.api_identity_fingerprint = Some(api_identity_fingerprint); + write_resource_edit_ledger(root, ledger)?; + } let client = reqwest::Client::builder() .timeout(Duration::from_secs(35 * 60)) .build() @@ -1363,7 +1397,7 @@ async fn prepare_remote_resource_edit( root, &client, &api_base_url, - &access_token, + &api_key, input, source, ledger, @@ -1389,19 +1423,16 @@ async fn prepare_remote_resource_edit( write_resource_edit_ledger(root, ledger)?; } let generated = if let Some(operation_id) = ledger.remote_operation_id.as_deref() { - wait_for_resource_edit_remote(&client, &api_base_url, &access_token, operation_id) - .await? + wait_for_resource_edit_remote(&client, &api_base_url, &api_key, operation_id).await? } else { let submission = - submit_resource_edit_remote(&client, &api_base_url, &access_token, ledger).await?; - if let Some(operation_id) = resource_edit_operation_id(&submission) { - ledger.remote_operation_id = Some(operation_id.clone()); - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; - wait_for_resource_edit_remote(&client, &api_base_url, &access_token, &operation_id) - .await? - } else { - external_editor_response_data(&submission).clone() - } + submit_resource_edit_remote(&client, &api_base_url, &api_key, ledger).await?; + let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { + "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() + })?; + ledger.remote_operation_id = Some(operation_id.clone()); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; + wait_for_resource_edit_remote(&client, &api_base_url, &api_key, &operation_id).await? }; let (resource_id, object_key, asset_object_id, model) = extract_resource_edit_remote_identity(&generated)?; @@ -1413,14 +1444,15 @@ async fn prepare_remote_resource_edit( object_key }; let download_source = serde_json::json!({ "objectKey": object_key }); - let download = resolve_authenticated_canvas_resource_download_with_limit( + let download = resolve_canvas_resource_download_with_limit( &client, &api_base_url, - &access_token, + &api_key, &download_source, media_read_limit(&input.edit_kind), ) - .await? + .await + .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; let (media_type, extension) = validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; @@ -1906,6 +1938,7 @@ pub(crate) async fn derive_local_project_resource_at( source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), + api_identity_fingerprint: None, phase: ResourceEditLedgerPhase::Prepared, endpoint: None, request_body_json: None, @@ -1978,9 +2011,69 @@ pub(crate) async fn derive_local_project_resource_at( #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; const PROJECT_ID: &str = "resource-editor-test-project"; + fn read_http_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set resource editor 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 resource editor request"); + assert!(read > 0, "resource editor 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 resource editor body"); + assert!(read > 0, "resource editor request closed before body"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() + } + + 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 resource editor JSON response"); + } + + fn write_bytes(stream: &mut TcpStream, status: &str, media_type: &str, bytes: &[u8]) { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len(), + ) + .expect("write resource editor media headers"); + stream + .write_all(bytes) + .expect("write resource editor media bytes"); + } + fn input( root: &Path, operation_id: String, @@ -2003,7 +2096,6 @@ mod tests { source_version_id: None, prompt: "保留原意并补充红发角色设定".to_string(), asset_name: "规则编辑版".to_string(), - access_token: None, } } @@ -2025,6 +2117,7 @@ mod tests { source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + api_identity_fingerprint: None, phase, endpoint: None, request_body_json: None, @@ -2141,9 +2234,13 @@ mod tests { source.canonical_resource_id.clone(), ); bgm.prompt = prompt.to_string(); - let (_, bgm_body) = + let (bgm_endpoint, bgm_body) = resource_edit_remote_request(&bgm, &source, prompt, "主题音乐编辑版", None) .expect("build BGM request"); + assert_eq!( + bgm_endpoint, + "/api/external/v1/editor/audios/background-music/generations" + ); assert_eq!( bgm_body.pointer("/generationInputs/source"), Some(&serde_json::json!(RESOURCE_EDIT_QUEUE_SOURCE)) @@ -2159,7 +2256,7 @@ mod tests { let mut video = bgm.clone(); video.edit_kind = LocalProjectResourceEditKind::Video; - let (_, video_body) = resource_edit_remote_request( + let (video_endpoint, video_body) = resource_edit_remote_request( &video, &source, prompt, @@ -2167,11 +2264,221 @@ mod tests { Some("stable-video-reference"), ) .expect("build video request"); + assert_eq!(video_endpoint, "/api/external/v1/editor/videos/generations"); assert_eq!(video_body["webSearchEnabled"], false); assert_eq!( video_body.pointer("/generationInputs/source"), Some(&serde_json::json!(RESOURCE_EDIT_QUEUE_SOURCE)) ); + + let mut sound_effect = bgm.clone(); + sound_effect.edit_kind = LocalProjectResourceEditKind::SoundEffect; + let (sound_effect_endpoint, _) = + resource_edit_remote_request(&sound_effect, &source, prompt, "音效编辑版", None) + .expect("build sound effect request"); + assert_eq!( + sound_effect_endpoint, + "/api/external/v1/editor/audios/sound-effects/generations" + ); + + let mut image = bgm; + image.edit_kind = LocalProjectResourceEditKind::ImageReference; + let (image_endpoint, _) = resource_edit_remote_request( + &image, + &source, + prompt, + "图片编辑版", + Some("stable-image-reference"), + ) + .expect("build image request"); + assert_eq!(image_endpoint, "/api/external/v1/editor/images/edits"); + assert!(!is_external_resource_edit_endpoint( + "/api/editor/videos/generations" + )); + } + + #[tokio::test] + async fn video_edit_uses_external_upload_generation_poll_and_read_url_without_mutating_source() + { + let directory = tempfile::tempdir().expect("create External video edit fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "External 视频编辑测试") + .expect("initialize project"); + let source_video = b"\0\0\0\x18ftypisom\0\0\0\0isomiso2".to_vec(); + let generated_video = b"\0\0\0\x18ftypmp42\0\0\0\0mp42isom".to_vec(); + let uploaded = upload_local_asset_at(root, "source-video.mp4", "video/mp4", &source_video) + .expect("upload source video"); + let source_asset = read_existing_manifest_for_project(root) + .expect("read source manifest") + .assets + .into_iter() + .find(|asset| asset.id == uploaded.id) + .expect("find source video asset"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind External video server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("External video address") + ); + let upload_url = format!("{base_url}/upload-source-video"); + let result_url = format!("{base_url}/generated-video.mp4"); + let stable_source_key = format!( + "generated-character-drafts/editor/resource-editor-references/{PROJECT_ID}/source-video.mp4" + ); + let server_upload_url = upload_url.clone(); + let server_result_url = result_url.clone(); + let server_source_key = stable_source_key.clone(); + let server_generated_video = generated_video.clone(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..7 { + let (mut stream, _) = listener.accept().expect("accept External video request"); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture External video request"); + let request_line = request.lines().next().unwrap_or_default(); + let request_lower = request.to_ascii_lowercase(); + if request_line.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { + assert!(request_lower + .contains("authorization: bearer resource-editor-external-key")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": server_upload_url, + "bucket": "private-test-bucket", + "objectKey": server_source_key, + "successActionStatus": 204, + "formFields": { + "key": server_source_key, + "policy": "private-upload-policy", + "signature": "private-upload-signature" + } + }}}), + ); + } else if request_line.starts_with("POST /upload-source-video ") { + write_bytes(&mut stream, "204 No Content", "text/plain", &[]); + } else if request_line.starts_with("POST /api/external/v1/assets/objects/confirm ") + { + assert!(request_lower + .contains("authorization: bearer resource-editor-external-key")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": server_source_key, + "assetObjectId": "source-video-object" + }}}), + ); + } else if request_line + .starts_with("POST /api/external/v1/editor/videos/generations ") + { + assert!(request_lower + .contains("authorization: bearer resource-editor-external-key")); + assert!(request_lower.contains("idempotency-key:")); + assert!(request.contains(&server_source_key)); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "external-video-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } else if request_line + .starts_with("GET /api/external/v1/generations/external-video-operation ") + { + assert!(request_lower + .contains("authorization: bearer resource-editor-external-key")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "external-video-operation", + "status": "completed", + "result": { + "resource": { + "resourceId": "external-video-resource", + "objectKey": "generated/result-video.mp4", + "assetObjectId": "generated-video-object" + }, + "model": "seedance2.0-fast" + } + }}), + ); + } else if request_line.starts_with("GET /api/external/v1/assets/read-url?") { + assert!(request_lower + .contains("authorization: bearer resource-editor-external-key")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": { + "signedUrl": server_result_url + }}}), + ); + } else if request_line.starts_with("GET /generated-video.mp4 ") { + write_bytes(&mut stream, "200 OK", "video/mp4", &server_generated_video); + } else { + panic!("unexpected External video request: {request_line}"); + } + } + }); + let api_key = "resource-editor-external-key"; + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), + ); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.asset_name = "源视频编辑版".to_string(); + + let result = derive_local_project_resource_at(request.clone()) + .await + .expect("derive External video"); + server.join().expect("join External video server"); + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 7); + assert!(requests.iter().all(|request| { + !request.starts_with("POST /api/editor/") + && !request.starts_with("POST /api/assets/") + && !request.starts_with("GET /api/runtime/external-generation/") + })); + let derivative = result.asset.expect("derived video asset"); + assert_ne!(derivative.id, source_asset.id); + assert_eq!( + fs::read(root.join(&source_asset.local_path)).expect("read preserved source video"), + source_video + ); + assert_eq!( + fs::read(root.join(&derivative.local_path)).expect("read derived video"), + generated_video + ); + assert!(result + .manifest + .assets + .iter() + .any(|asset| asset.id == source_asset.id)); + let persisted = + fs::read_to_string(root.join(resource_edit_ledger_path(&request.operation_id))) + .expect("read External resource edit ledger"); + for private_value in [ + api_key, + "Authorization", + "private-upload-policy", + "private-upload-signature", + upload_url.as_str(), + result_url.as_str(), + ] { + assert!(!persisted.contains(private_value)); + } } #[test] diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts index 718eccc31..20b16908c 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts @@ -15,7 +15,6 @@ import type { GameCreationAppAssetManifestEntry, GameCreationAppManifest, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { getStoredAuthAccessToken } from '../../services/clientApi'; const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER; export const LOCAL_ASSET_COMMITTED_EVENT = @@ -165,6 +164,8 @@ function failure(error: unknown): ImageCanvasHostResult { ? 'reconciliation-required' : message.includes('project-identity-conflict') ? 'project-identity-conflict' + : normalized.includes('external editor api') + ? 'platform-service-configuration' : normalized.includes('authentication-required') || message.includes('登录已失效') ? 'authentication-required' @@ -268,10 +269,6 @@ export function createTauriImageCanvasHostAdapter(input: { async generateImage(generationInput) { let unlisten: (() => void) | undefined; try { - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('authentication-required: 登录已失效,请重新登录后重试'); - } unlisten = await listenGenerationProgress( generationInput.scope, generationInput.generationId, @@ -300,7 +297,6 @@ export function createTauriImageCanvasHostAdapter(input: { assetKind: generationInput.assetKind, assetName: generationInput.assetName, referenceResourceIds: generationInput.referenceResourceIds, - accessToken, }, ); return { status: 'ok', value: result }; @@ -313,10 +309,6 @@ export function createTauriImageCanvasHostAdapter(input: { async recoverImages(recoverInput) { let unlisten: (() => void) | undefined; try { - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('authentication-required: 登录已失效,请重新登录后重试'); - } unlisten = await listenGenerationProgress( recoverInput.scope, null, @@ -324,7 +316,7 @@ export function createTauriImageCanvasHostAdapter(input: { ); const result = await invokeInput<{ resumedGenerationIds: string[] }>( 'recover_local_project_asset_canvas_generations', - { ...baseScope(recoverInput.scope), accessToken }, + baseScope(recoverInput.scope), ); return { status: 'ok', value: result }; } catch (error) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 40ee4ce1f..3e43ae953 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -56,7 +56,6 @@ import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { getStoredAuthAccessToken } from '../../services/clientApi'; import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, @@ -1418,14 +1417,6 @@ export default function ProjectDevelopmentView({ if (!route || !invoke) { throw new Error('资源编辑需要在客户端内打开'); } - const accessToken = route.capability.requiresAccessToken - ? getStoredAuthAccessToken() - : ''; - if (route.capability.requiresAccessToken && !accessToken) { - throw new Error( - 'authentication-required: 登录已失效,请重新登录后重试', - ); - } let expectedProjectRevision = resourceEditorRevisionRef.current.get( route.operationId, ); @@ -1466,7 +1457,6 @@ export default function ProjectDevelopmentView({ sourceVersionId: resource.version?.versionId ?? null, prompt, assetName, - accessToken: accessToken || null, }, }, ); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts index fdf27fdf4..a1c6c7454 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts @@ -27,7 +27,6 @@ export type ProjectResourceEditCapability = route: 'derive'; editKind: LocalProjectResourceEditKind; sourceMediaType: string; - requiresAccessToken: boolean; semanticNotice: string | null; }; @@ -126,7 +125,6 @@ export function resolveProjectResourceEditCapability( route: 'derive', editKind: 'version', sourceMediaType: 'application/vnd.genarrative.project-version+json', - requiresAccessToken: false, semanticNotice: null, }; } @@ -135,7 +133,6 @@ export function resolveProjectResourceEditCapability( route: 'derive', editKind: 'agent-result', sourceMediaType: 'text/markdown', - requiresAccessToken: false, semanticNotice: null, }; } @@ -161,7 +158,6 @@ export function resolveProjectResourceEditCapability( route: 'derive', editKind: 'svg', sourceMediaType: mediaType, - requiresAccessToken: false, semanticNotice: null, }; } @@ -170,7 +166,6 @@ export function resolveProjectResourceEditCapability( route: 'derive', editKind: 'image-reference', sourceMediaType: mediaType, - requiresAccessToken: true, semanticNotice: null, }; } @@ -179,7 +174,6 @@ export function resolveProjectResourceEditCapability( route: 'derive', editKind: 'video', sourceMediaType: mediaType, - requiresAccessToken: true, semanticNotice: null, }; } @@ -190,7 +184,6 @@ export function resolveProjectResourceEditCapability( ? 'background-music' : 'sound-effect', sourceMediaType: mediaType, - requiresAccessToken: true, semanticNotice: '音频将基于原资源语义派生重制,不会修改原音频波形。', }; } @@ -198,7 +191,6 @@ export function resolveProjectResourceEditCapability( route: 'derive', editKind: 'text', sourceMediaType: mediaType, - requiresAccessToken: false, semanticNotice: null, }; } diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 2ccc753ab..6899aca82 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -1366,12 +1366,15 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect(invokeSpy).toHaveBeenCalledTimes(3); }); - it('Tauri adapter 只把登录 Token 临时传给生成命令,并在公开结果中移除', async () => { + it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => { const accessToken = 'session-token-must-stay-ephemeral'; setStoredAuthAccessToken(accessToken); const manifest = manifestFixture(scope.projectId); const invokeSpy = vi.fn( async (command: string, args?: Record) => { + if (command === 'recover_local_project_asset_canvas_generations') { + return { resumedGenerationIds: [] }; + } if (command !== 'generate_local_project_asset_canvas_image') { throw new Error(`unexpected command: ${command}`); } @@ -1422,11 +1425,11 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { idempotencyKey: '33333333-3333-4333-8333-333333333333', commitId: '44444444-4444-4444-8444-444444444444', commitIdempotencyKey: '55555555-5555-4555-8555-555555555555', - prompt: '登录态图片生成', + prompt: 'External 图片生成', aspectRatio: '16:9', imageSize: '1K', assetKind: 'illustration', - assetName: '登录态素材', + assetName: 'External 素材', referenceResourceIds: [], }); @@ -1436,17 +1439,22 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { string, unknown >; - expect(commandInput.accessToken).toBe(accessToken); + expect(commandInput).not.toHaveProperty('accessToken'); + expect(commandInput).not.toHaveProperty('apiKey'); expect(JSON.stringify(result)).not.toContain(accessToken); clearStoredAuthAccessToken(); - const missingAuth = await adapter.generation.recoverImages({ scope }); - expect(missingAuth).toEqual( - expect.objectContaining({ - status: 'failed', - code: 'authentication-required', - }), - ); - expect(invokeSpy).toHaveBeenCalledTimes(1); + const recovery = await adapter.generation.recoverImages({ scope }); + expect(recovery).toEqual({ + status: 'ok', + value: { resumedGenerationIds: [] }, + }); + const recoveryInput = invokeSpy.mock.calls[1]?.[1]?.input as Record< + string, + unknown + >; + expect(recoveryInput).not.toHaveProperty('accessToken'); + expect(recoveryInput).not.toHaveProperty('apiKey'); + expect(invokeSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index cf83fceda..3d206a324 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -532,6 +532,10 @@ describe('project resource live canvas integration', () => { await waitFor(() => expect(deriveCalls).toHaveLength(2)); expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId); expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey); + expect(deriveCalls[0]).not.toHaveProperty('accessToken'); + expect(deriveCalls[0]).not.toHaveProperty('apiKey'); + expect(deriveCalls[1]).not.toHaveProperty('accessToken'); + expect(deriveCalls[1]).not.toHaveProperty('apiKey'); const operationId = String(deriveCalls[1]?.operationId); expect( await screen.findByRole('region', { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e4ea5aa45..113dd46a3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-08-10 Tauri 客户端远端资源编辑固定使用 External v1 + +- 产品边界:主站网页画布继续使用登录态 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`;AI 游戏创作 Tauri 客户端没有网页画布宿主,图片、图片引用、视频、音效和背景音乐等远端媒体编辑固定使用 `/api/external/v1/*`。两条入口继续复用相同请求 DTO、owner 归属、统一生成队列和正式资产结果,不新建平行生成服务。 +- 凭据边界:客户端沿用发布 AppData 私有运行时配置中的 `editorApi.baseUrl/apiKey`,不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把 API Key 打包进仓库、传入 WebView、写入项目、账本、日志或普通错误。Key 缺失投影安全配置错误,`401/403` 投影 External 凭据无效或权限不足,不再描述为站内登录失效。 +- 路由边界:图片编辑、视频、音效和 BGM 分别使用 `/api/external/v1/editor/images/edits`、`/api/external/v1/editor/videos/generations`、`/api/external/v1/editor/audios/sound-effects/generations` 与 `/api/external/v1/editor/audios/background-music/generations`;上传、确认、轮询和换签固定使用 External v1 对应端点。SVG、UTF-8 文档、代码、Agent 回执与项目版本仍是本地派生,不制造无意义的远端请求。 +- 可靠性边界:External POST 固定携带原稳定 `Idempotency-Key` 并只接受 `202 + operationId`;`prepared` 只重放原正文和原键,`accepted/running` 只查询 `/api/external/v1/generations/{operationId}`。账本绑定不含明文 Key 的 External 配置身份指纹;升级前已保存的站内 endpoint 只保留为待对账状态,禁止拿 External Key 自动重放。本地参考媒体继续严格执行 ticket → OSS form → confirm,结果只消费稳定 `objectKey/resource/asset`,旧资源保留且派生资源追加。 +- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts`、`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 + ## 2026-08-10 客户端参考媒体直传复用已授权私有前缀 - 根因:客户端素材画布和全类型资源编辑把内部用途目录 `asset-canvas-references`、`resource-editor-references` 直接作为 `legacyPrefix`;api-server 只接受 `platform-oss` 权威白名单,因此请求在票据阶段返回 `400`,OSS 上传、对象确认、生成提交和扣费都没有发生。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4fe20ce95..11d94fe5c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4509,6 +4509,14 @@ - 验证:客户端端到端测试必须断言 confirm 早于生成 POST、请求使用精确前缀与 pathSegments、账本只持久化稳定对象身份;票据失败时断言 `operationId/requestBodyJson` 为空且 manifest 只有源资产。api-server 测试应断言生成的 key 位于 `generated-character-drafts/editor/...` 且 access 为 private。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`server-rs/crates/api-server/src/assets.rs`。 +## Tauri 有平台登录 Token 不代表应调用主站画布 API(2026-08-10) + +- 现象:客户端素材画布和全类型资源编辑从 WebView 读取平台 Access Token,把它传给 Tauri command,再调用 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*`;代码同时保留 External 分支,导致真实 UI、Runtime 和测试使用不同路径,发布客户端还错误依赖网页画布登录态。 +- 原因:把“客户端壳有账号登录能力”误当成“客户端画布属于主站网页宿主”。主站和 Tauri 虽复用相同请求 DTO 与后端生成服务,但对外边界不同:主站使用站内认证路由,Tauri 远端媒体能力使用 Developer API Key 和 External v1 路由。 +- 处理:Tauri 前端不读取、透传或持久化站内 Access Token;Rust 只从发布 AppData 私有 `editorApi.baseUrl/apiKey` 解析 External 凭据。图片、视频、音效、BGM 的项目/素材库、上传、确认、生成、轮询与换签全部留在 `/api/external/v1`,不访问内部 job 查询或账号/profile 接口。账本只绑定 External 配置身份指纹,升级前遗留的站内 endpoint 必须进入待对账状态,不能拿 External Key 自动重放。Key 缺失或无权限只返回安全配置错误,不打印 Key、Authorization、Provider 正文或私有路径。 +- 验证:前端测试断言 command input 不含 `accessToken/apiKey`;Rust mock 服务器拒绝任何 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 请求,并覆盖 External `202`、原 operation 轮询、换签、非破坏性本地提交和账本零凭据。主站路由与 OpenAPI 未发生契约变化时不得为了客户端切换修改后端接口。 +- 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 + ## prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05) - 现象:事务依次安装 before/after 快照后才写 journal;若进程在首个快照、全部快照或 journal 已写但 ledger 未写时退出,重启扫描看到 transaction 目录却无法进入原先只覆盖 prepared 之后的恢复状态机,可能留下孤儿目录或阻塞项目后续提交。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f6d976327..f2aa3a3b3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -971,8 +971,8 @@ game-project/ - 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用现有编辑器项目、统一生成队列、资产上传/换签、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。 - 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。 -- 普通客户模式只使用当前平台登录态调用 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`;后端从登录身份解析 owner、按现有统一生成队列预扣/退款泥点,并只在服务端读取供应商配置。客户 UI 不显示 Base URL / API Key,也不得把根目录 `.env*`、供应商 Key 或开发者 Key 打包给客户端。`/api/external/v1` 与开发者 API Key 继续保留给第三方 Agent、CLI 和内部验收,不能用开发者 Key 替代客户身份或计费归属。 -- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。登录 Access Token、API Key、Authorization、Provider URL、签名 URL和绝对路径不进入账本或项目事实;登录 Token 刷新不得触发 `configuration-changed`。草稿/manifest/event/日志只投影安全阶段与本地身份,远端 taskId 不写 manifest `source.taskId`。 +- 主站网页画布继续使用当前平台登录态调用 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`。AI 游戏创作 Tauri 客户端的图片、图片引用、视频、音效和背景音乐远端编辑固定调用 `/api/external/v1/*`,凭据只读取发布 AppData 私有 `editorApi.baseUrl/apiKey`;不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把开发者 Key 打包进客户端或传入 WebView。两条入口继续由后端解析 owner、沿统一生成队列预扣/退款泥点并只在服务端读取供应商配置。 +- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入账本或项目事实;External 配置指纹变化不得借旧 Key 身份自动重放。草稿/manifest/event/日志只投影安全阶段与本地身份,远端 taskId 不写 manifest `source.taskId`。 - 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。 - 应用重启时 `accepted/running` 只恢复 GET,`prepared` 只以私有账本原请求字节和原键恢复同一 POST。用户停止等待只失效当前 focus generation;不可取消的远端操作继续 reconcile,迟到 completed 可以更新原项目但不能抢焦点。配置缺失、结果引用不稳定、下载/提交失败和未知结果都不得显示资源创建成功。 - 最终审计后 Tauri 撤销/重做直接使用与网站相同的共享 `useCanvasHistory`,共享 history 同时恢复 viewport、选择、图层位置和缩放边界;Tauri 自身只保留 Pointer 事件接线,平移、缩放、选择、图层变换和 renderer 算法继续来自共享 core/react。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index 242daadb6..3de2839bc 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -952,13 +952,13 @@ confirmation-required ### 14.4 首版请求范围与恢复 -- 普通客户生成使用 `POST /api/editor/images/generations`,refine 使用 `POST /api/editor/images/edits`;两者携带当前登录 Bearer Token 和稳定 `Idempotency-Key`,由后端将 owner 与幂等身份绑定并沿统一生成队列扣费。提交响应中的 `queueState.operationId` 是轮询身份,状态使用 `GET /api/runtime/external-generation/jobs/{operationId}`;completed 必须返回与 External 通道一致语义的 compact `result`,供客户端解析稳定引用。 -- 开发者模式才使用对应 `/api/external/v1/editor/images/*`;两种模式支持相同 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9`、`0.5K | 1K | 2K`、合法 `assetKind` 与参考资源约束。refine 的 `sourceImageSrc` 必须来自源资产的 objectKey、服务端项目 resourceId 或素材 assetId;`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走现有 direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有服务端 resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。 +- 主站网页画布继续使用 `POST /api/editor/images/generations`、`POST /api/editor/images/edits` 与 `/api/runtime/external-generation/jobs/{operationId}`。AI 游戏创作 Tauri 客户端固定使用对应 `/api/external/v1/editor/images/*`,携带发布 AppData 私有 `editorApi.apiKey` 和稳定 `Idempotency-Key`,状态只查询 `GET /api/external/v1/generations/{operationId}`。两条入口复用相同请求 DTO、owner 归属和统一生成队列;客户端不读取或透传主站 Access Token,也不实现登录后自动签发 Developer API Key。 +- External 图片生成和编辑支持相同 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9`、`0.5K | 1K | 2K`、合法 `assetKind` 与参考资源约束。refine 的 `sourceImageSrc` 必须来自源资产的 objectKey、服务端项目 resourceId 或素材 assetId;`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走 External direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有服务端 resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。 - 客户端参考媒体直传固定复用 `legacyPrefix=generated-character-drafts`,不得把内部用途目录作为新 legacy prefix,也不得扩大服务端白名单。图片画布的 `pathSegments` 固定为 `editor / asset-canvas-references / / / `;全类型资源编辑的本地视频、音频等源媒体固定为 `editor / resource-editor-references / / `。两条路径都只持久化 confirm 后的稳定 objectKey,不持久化 ticket 或签名 URL。 - 图片参考资源准备失败按阶段投影安全错误码:本地读取/校验为 `reference-material-invalid`,票据为 `reference-ticket-failed`,OSS 表单上传为 `reference-object-upload-failed`,对象确认为 `reference-confirm-failed`;`401/403` 仍为 `authentication-required`。任一阶段失败都必须保持 External `operationId=null`、生成 endpoint/request body 未建立,不得进入扣费或生成提交。普通错误不得包含 ticket host、formFields、policy、signature、Token、API Key、Provider 响应正文或本机绝对路径。 - `accepted/running` 重启只查询原 operation;`prepared` 只以账本中的原 endpoint、原始字节和原键恢复同一 POST。轮询超时、临时鉴权/网关错误或结果未知保持可恢复,不自动转 failed 或换键扣费。 - 用户“停止等待”只撤销当前 session 的聚焦意图,不伪装远端取消。后台 command 可以继续安全 reconcile;应用退出后由项目恢复扫描继续原 operation。迟到完成可以更新原项目权威缓存和 manifest,但不能切回旧画布、清搜索或抢焦点。 -- 登录失效、余额不足、平台生成服务配置错误、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。客户模式不得把缺少开发者 API Key 显示为失败原因。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。 +- External API Key 缺失、无效或权限不足,余额不足、平台生成服务配置错误、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。错误只说明需要配置或检查 External Editor 凭据,不回显 Key、Authorization 或上游正文。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。 ## 15. 2026-08-06 画布 UI 对齐阶段三至五收口 @@ -978,16 +978,16 @@ confirmation-required - 资源总览的“新增资源”保留为明确禁用态,入口处理函数也必须拒绝 create,不能只依赖按钮外观阻止进入空白画布。 - 资源聚焦态的所有现役资源均提供“编辑资源”,覆盖 manifest asset、已完成任务产物、已导入附件、Agent 文本回执和项目版本。后端必须按 manifest、任务完成态、上传登记或回执身份重新核验来源;没有唯一来源身份的本地媒体不得仅凭前端路径进入编辑。 - 静态 PNG / JPEG / WebP 继续使用 `AssetCanvasSurface + intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用。任务产物或附件中的静态图片必须先正规化为正式 manifest asset,再进入现有图片画布。 -- 普通客户确认编辑后固定调用 `POST /api/editor/images/edits`;提示词、比例、尺寸、资源用途、登录态、泥点计费和原 operation 恢复继续复用现有生成合同。 +- 客户端确认编辑后固定调用 `POST /api/external/v1/editor/images/edits`;提示词、比例、尺寸、资源用途、泥点计费和原 operation 恢复继续复用现有生成合同,凭据只来自发布 AppData 私有 `editorApi.apiKey`。 - SVG、UTF-8 文档、代码和 Agent 文本回执使用文本差异派生:把源内容当作不可信数据交给当前客户端 LLM,响应必须是完整、唯一的结构化内容 envelope;JSON、SVG 等可校验格式必须在落盘前重新校验。结果写入新的本地路径和 manifest asset,不能直接写回源文件。Agent 回执原记录不转写、不删除,新 asset 以回执资源身份登记血缘。 -- 视频使用现役 `POST /api/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 direct-upload ticket、OSS 表单上传和 confirm,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。 -- 音效和背景音乐分别使用现役 `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为“基于原资源语义的派生重制”,界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。 +- 视频使用 `POST /api/external/v1/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 External direct-upload ticket、OSS 表单上传和 confirm,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。 +- 音效和背景音乐分别使用 `POST /api/external/v1/editor/audios/sound-effects/generations` 与 `POST /api/external/v1/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为“基于原资源语义的派生重制”,界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。 - 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。 - 所有编辑结果固定创建新的本地 asset、文件路径、版本 ID 或资源身份。源 asset、源文件、Agent 回执与源版本不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧资源。 - 各类型统一使用稳定 `operationId / Idempotency-Key`。远端 `202` 只表示受理,必须轮询原 operation;提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifest,manifest 只保存稳定 objectKey 对应的资源 / 资产身份。 -- 登录态图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;视频和音频登录态路由必须实际读取并传递同一稳定 `Idempotency-Key`,不能只由客户端发送后在路由层丢弃。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。 +- External 图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;所有 External 路由必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。 - 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。 -- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;登录刷新后只继续同一 operation。 +- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;修正 External Editor API Key 配置后只继续同一 operation。 - 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。 - 图层选择与图层缩放必须同时提供指针和键盘路径;不得嵌套 button/role=button。Enter/Space 可选择图层,缩放手柄使用原生 button 并提供方向键离散缩放。