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 02fcb0b17..8ba710c65 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 @@ -9,6 +9,8 @@ pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str = const ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES: usize = 512 * 1024; const ASSET_CANVAS_GENERATION_REFERENCE_LIMIT: usize = 9; const ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; +const ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; +const ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE: &str = "asset-canvas-references"; static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< tokio::sync::Mutex>>>, @@ -252,6 +254,43 @@ struct ReferenceMaterial { sha256: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReferencePreparationError { + AuthenticationRequired, + MaterialInvalid, + TicketFailed, + ObjectUploadFailed, + ConfirmFailed, + Internal, +} + +impl ReferencePreparationError { + fn code(self) -> &'static str { + match self { + Self::AuthenticationRequired => "authentication-required", + Self::MaterialInvalid => "reference-material-invalid", + Self::TicketFailed => "reference-ticket-failed", + Self::ObjectUploadFailed => "reference-object-upload-failed", + Self::ConfirmFailed => "reference-confirm-failed", + Self::Internal => "generation-failed", + } + } + + fn from_http_error(error: &str, fallback: Self) -> Self { + if error.contains("HTTP 401") || error.contains("HTTP 403") { + Self::AuthenticationRequired + } else { + fallback + } + } +} + +impl From for ReferencePreparationError { + fn from(_: String) -> Self { + Self::Internal + } +} + enum CanvasGenerationApiMode { Authenticated { access_token: String }, ExternalDeveloper { api_key: String }, @@ -924,7 +963,7 @@ async fn try_confirm_uploaded_reference( object_key: &str, material: &ReferenceMaterial, asset_kind: &str, -) -> Result, String> { +) -> Result, ReferencePreparationError> { let endpoint = api_mode.endpoint( "/api/assets/objects/confirm", "/api/external/v1/assets/objects/confirm", @@ -945,26 +984,32 @@ async fn try_confirm_uploaded_reference( ) .send() .await - .map_err(|_| "确认参考资源上传结果失败".to_string())?; + .map_err(|_| ReferencePreparationError::ConfirmFailed)?; + if matches!( + response.status(), + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { + return Err(ReferencePreparationError::AuthenticationRequired); + } if !response.status().is_success() { return Ok(None); } let payload = response .json::() .await - .map_err(|_| "解析参考资源确认响应失败".to_string())?; + .map_err(|_| ReferencePreparationError::ConfirmFailed)?; let asset_object = external_editor_response_data(&payload) .get("assetObject") .or_else(|| payload.pointer("/data/assetObject")) .unwrap_or(&serde_json::Value::Null); let confirmed_object_key = json_string_field(asset_object, "objectKey") - .ok_or_else(|| "参考资源确认响应缺少 objectKey".to_string())?; + .ok_or(ReferencePreparationError::ConfirmFailed)?; if confirmed_object_key != object_key { - return Err("参考资源确认响应 objectKey 不一致".to_string()); + return Err(ReferencePreparationError::ConfirmFailed); } json_string_field(asset_object, "assetObjectId") .map(Some) - .ok_or_else(|| "参考资源确认响应缺少 assetObjectId".to_string()) + .ok_or(ReferencePreparationError::ConfirmFailed) } async fn request_upload_ticket( @@ -973,7 +1018,7 @@ async fn request_upload_ticket( api_mode: &CanvasGenerationApiMode, ledger: &AssetCanvasGenerationLedger, material: &ReferenceMaterial, -) -> Result { +) -> Result { let endpoint = api_mode.endpoint( "/api/assets/direct-upload-tickets", "/api/external/v1/assets/direct-upload-tickets", @@ -983,8 +1028,8 @@ async fn request_upload_ticket( client .post(format!("{api_base_url}{endpoint}")) .json(&serde_json::json!({ - "legacyPrefix": "asset-canvas-references", - "pathSegments": [ledger.project_id.as_str(), ledger.draft_id.as_str(), ledger.generation_id.as_str()], + "legacyPrefix": ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX, + "pathSegments": ["editor", ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE, ledger.project_id.as_str(), ledger.draft_id.as_str(), ledger.generation_id.as_str()], "fileName": material.file_name, "contentType": material.media_type, "access": "private", @@ -996,44 +1041,49 @@ async fn request_upload_ticket( "创建参考资源上传凭证", ) .await - .map_err(|_| "创建参考资源上传凭证失败".to_string())?; + .map_err(|error| { + ReferencePreparationError::from_http_error( + &error, + ReferencePreparationError::TicketFailed, + ) + })?; let upload = external_editor_response_data(&payload) .get("upload") .or_else(|| payload.pointer("/data/upload")) - .ok_or_else(|| "参考资源上传凭证缺少 upload".to_string())?; + .ok_or(ReferencePreparationError::TicketFailed)?; let host = json_string_field(upload, "host") .or_else(|| json_string_field(upload, "endpoint")) - .ok_or_else(|| "参考资源上传凭证缺少 host".to_string())?; - let bucket = json_string_field(upload, "bucket") - .ok_or_else(|| "参考资源上传凭证缺少 bucket".to_string())?; - let object_key = json_string_field(upload, "objectKey") - .ok_or_else(|| "参考资源上传凭证缺少 objectKey".to_string())?; + .ok_or(ReferencePreparationError::TicketFailed)?; + let bucket = + json_string_field(upload, "bucket").ok_or(ReferencePreparationError::TicketFailed)?; + let object_key = + json_string_field(upload, "objectKey").ok_or(ReferencePreparationError::TicketFailed)?; let success_action_status = upload .get("successActionStatus") .and_then(serde_json::Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or_else(|| "参考资源上传凭证 successActionStatus 无效".to_string())?; + .ok_or(ReferencePreparationError::TicketFailed)?; let max_size_bytes = upload .get("maxSizeBytes") .and_then(serde_json::Value::as_u64) - .ok_or_else(|| "参考资源上传凭证缺少 maxSizeBytes".to_string())?; + .ok_or(ReferencePreparationError::TicketFailed)?; if material .bytes .as_ref() .is_some_and(|bytes| bytes.len() as u64 > max_size_bytes) { - return Err("参考资源超过上传凭证大小上限".to_string()); + return Err(ReferencePreparationError::TicketFailed); } let form_fields = upload .get("formFields") .and_then(serde_json::Value::as_object) - .ok_or_else(|| "参考资源上传凭证缺少 formFields".to_string())? + .ok_or(ReferencePreparationError::TicketFailed)? .iter() .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string()))) .collect::>(); if form_fields.is_empty() { - return Err("参考资源上传凭证 formFields 为空".to_string()); + return Err(ReferencePreparationError::TicketFailed); } Ok(PrivateUploadTicket { host, @@ -1048,24 +1098,24 @@ async fn upload_reference( ticket: &PrivateUploadTicket, material: &ReferenceMaterial, api_base_url: &str, -) -> Result<(), String> { +) -> Result<(), ReferencePreparationError> { let bytes = material .bytes .as_ref() - .ok_or_else(|| "参考资源上传缺少图片内容".to_string())?; + .ok_or(ReferencePreparationError::ObjectUploadFailed)?; let media_type = material .media_type .as_deref() - .ok_or_else(|| "参考资源上传缺少 mediaType".to_string())?; + .ok_or(ReferencePreparationError::ObjectUploadFailed)?; let file_name = material .file_name .as_deref() - .ok_or_else(|| "参考资源上传缺少文件名".to_string())?; + .ok_or(ReferencePreparationError::ObjectUploadFailed)?; let upload_url = validate_external_asset_download_url(&ticket.host, api_base_url, true) - .map_err(|_| "参考资源上传地址无效".to_string())?; + .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; let client = build_external_asset_download_client(&upload_url, api_base_url, true) .await - .map_err(|_| "创建参考资源安全上传客户端失败".to_string())?; + .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -1073,15 +1123,15 @@ async fn upload_reference( let part = Part::bytes(bytes.clone()) .file_name(file_name.to_string()) .mime_str(media_type) - .map_err(|_| "参考资源 mediaType 无法用于上传".to_string())?; + .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传参考资源失败".to_string())?; + .map_err(|_| ReferencePreparationError::ObjectUploadFailed)?; if response.status().as_u16() != ticket.success_action_status { - return Err("上传参考资源返回非预期状态".to_string()); + return Err(ReferencePreparationError::ObjectUploadFailed); } Ok(()) } @@ -1092,7 +1142,7 @@ async fn ensure_reference_states( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, -) -> Result<(), String> { +) -> Result<(), ReferencePreparationError> { set_private_phase( root, ledger, @@ -1101,7 +1151,8 @@ async fn ensure_reference_states( )?; let requested = ledger.requested_reference_resource_ids.clone(); for resource_id in requested { - let material = reference_material_at(root, ledger, &resource_id)?; + let material = reference_material_at(root, ledger, &resource_id) + .map_err(|_| ReferencePreparationError::MaterialInvalid)?; let index = match ledger .reference_states .iter() @@ -1173,7 +1224,7 @@ async fn ensure_reference_states( &ledger.asset_kind, ) .await? - .ok_or_else(|| "参考资源上传后无法确认稳定对象".to_string())?; + .ok_or(ReferencePreparationError::ConfirmFailed)?; ledger.reference_states[index].stable_reference = Some(ticket.object_key); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -1187,10 +1238,10 @@ async fn ensure_reference_states( state .stable_reference .clone() - .ok_or_else(|| "参考资源缺少稳定远端引用".to_string()) + .ok_or(ReferencePreparationError::ConfirmFailed) }) .collect::, _>>()?; - write_generation_ledger(root, ledger) + write_generation_ledger(root, ledger).map_err(|_| ReferencePreparationError::ConfirmFailed) } fn external_generation_kind(asset_kind: &str) -> Option<&'static str> { @@ -1528,6 +1579,10 @@ fn sanitized_generation_error(code: &str) -> String { "generation-rejected" => "图片生成请求被平台明确拒绝,未创建资源".to_string(), "generation-failed" => "图片生成失败,未创建资源".to_string(), "stable-reference-missing" => "远端生成结果缺少稳定资源引用,未创建本地资源".to_string(), + "reference-material-invalid" => "参考资源读取或校验失败,未提交生成".to_string(), + "reference-ticket-failed" => "参考资源上传凭证申请失败,未提交生成".to_string(), + "reference-object-upload-failed" => "参考资源上传失败,未提交生成".to_string(), + "reference-confirm-failed" => "参考资源对象确认失败,未提交生成".to_string(), "reference-upload-failed" => "参考资源上传或确认失败,未提交生成".to_string(), "download-reconciliation-required" => { "reconciliation-required: 远端图片下载未完成,需要使用原 operation 恢复".to_string() @@ -1823,13 +1878,7 @@ async fn reconcile_generation( if let Err(error) = ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await { - let code = if api_mode.is_authenticated() - && (error.contains("HTTP 401") || error.contains("HTTP 403")) - { - "authentication-required" - } else { - "reference-upload-failed" - }; + let code = error.code(); mark_generation_error(root, &mut ledger, false, code, emit)?; return Err(sanitized_generation_error(code)); } @@ -2340,8 +2389,8 @@ mod tests { use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; - fn test_png() -> Vec { - let image = RgbaImage::from_pixel(4, 3, Rgba([21, 87, 180, 255])); + fn test_png_with_color(color: [u8; 4]) -> Vec { + let image = RgbaImage::from_pixel(4, 3, Rgba(color)); let mut output = Cursor::new(Vec::new()); DynamicImage::ImageRgba8(image) .write_to(&mut output, ImageFormat::Png) @@ -2349,6 +2398,10 @@ mod tests { output.into_inner() } + fn test_png() -> Vec { + test_png_with_color([21, 87, 180, 255]) + } + fn read_http_request(stream: &mut TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(2))) @@ -2402,6 +2455,14 @@ mod tests { .expect("write generation PNG response"); } + fn write_empty_response(stream: &mut TcpStream, status: &str) { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write empty generation fixture response"); + } + fn create_generation_fixture( project_id: &str, project_name: &str, @@ -2425,6 +2486,58 @@ mod tests { (directory, draft) } + fn create_refine_generation_fixture( + project_id: &str, + project_name: &str, + ) -> (tempfile::TempDir, AssetCanvasDraft, Vec) { + let directory = tempfile::tempdir().expect("create refine generation fixture"); + let mut initialized = + init_local_game_project_at(directory.path(), project_id, project_name) + .expect("initialize refine generation project"); + let source_png = test_png_with_color([38, 132, 76, 255]); + fs::write(directory.path().join("assets/source.png"), &source_png) + .expect("write refine source image"); + initialized + .manifest + .assets + .push(GameCreationAppAssetManifestEntry { + id: "source-character".to_string(), + kind: "illustration".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/source.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest( + &directory.path().join(".agent/manifest.json"), + &initialized.manifest, + ) + .expect("persist refine source manifest"); + let draft = create_asset_canvas_draft_at( + directory.path(), + &CreateAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: Uuid::new_v4().to_string(), + intent: AssetCanvasIntent::Refine, + source_asset_id: Some("source-character".to_string()), + }, + ) + .expect("create refine generation draft") + .draft; + (directory, draft, source_png) + } + fn generation_input( root: &Path, project_id: &str, @@ -2734,6 +2847,342 @@ mod tests { assert!(!progress_json.contains(absolute_project_path.as_ref())); } + #[tokio::test] + async fn local_refine_uploads_confirms_then_submits_and_preserves_the_source_asset() { + let project_id = "asset-canvas-reference-upload-project"; + let project_name = "素材画布本地图片精修测试"; + let (directory, draft, source_png) = + create_refine_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind refine generation server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("refine generation address") + ); + let upload_url = format!("{base_url}/oss-upload"); + let signed_url = format!("{base_url}/refined.png"); + let result_png = test_png_with_color([218, 42, 64, 255]); + let mut input = generation_input( + directory.path(), + project_id, + &draft, + "把人物头发变成红色,保持其它内容不变", + ); + input.asset_name = "红发角色".to_string(); + let reference_sha256 = asset_canvas_sha256(&source_png); + let expected_object_key = format!( + "generated-character-drafts/editor/asset-canvas-references/{}/{}/{}/reference-{}.png", + project_id, draft.draft_id, input.generation_id, reference_sha256 + ); + let server_object_key = expected_object_key.clone(); + let server_signed_url = signed_url.clone(); + let server_result_png = result_png.clone(); + let source_png_len = source_png.len(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..9 { + let (mut stream, _) = listener.accept().expect("accept refine generation request"); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture refine generation request"); + if request.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "remote-folder", + "label": project_name, + }]}}}), + ); + } else if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url.clone(), + "bucket": "stable-private-bucket", + "objectKey": server_object_key.clone(), + "successActionStatus": 204, + "maxSizeBytes": source_png_len, + "formFields": { + "key": server_object_key.clone(), + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": server_object_key.clone(), + "assetObjectId": "confirmed-reference-object" + }}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/images/edits ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "refine-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } else if request.starts_with("GET /api/external/v1/generations/refine-operation ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "refine-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "refined-remote-resource", + "objectKey": "generated/refined-result.png", + "assetObjectId": "refined-remote-object" + }} + }}), + ); + } else if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url.clone()}}), + ); + } else if request.starts_with("GET /refined.png ") { + write_png(&mut stream, &server_result_png); + } else { + panic!("unexpected refine generation request: {request}"); + } + } + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": base_url, + "apiKey": "refine-private-api-key" + }}) + .to_string(), + ); + + let execution = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + .await + .expect("upload local source and complete refine generation"); + server.join().expect("join refine generation server"); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 9); + let ticket_index = requests + .iter() + .position(|request| { + request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + }) + .expect("ticket request exists"); + let upload_index = requests + .iter() + .position(|request| request.starts_with("POST /oss-upload ")) + .expect("OSS upload request exists"); + let confirm_index = requests + .iter() + .position(|request| { + request.starts_with("POST /api/external/v1/assets/objects/confirm ") + }) + .expect("confirm request exists"); + let submit_index = requests + .iter() + .position(|request| request.starts_with("POST /api/external/v1/editor/images/edits ")) + .expect("refine submit request exists"); + assert!(ticket_index < upload_index && upload_index < confirm_index); + assert!(confirm_index < submit_index); + assert!(requests[ticket_index].contains("\"legacyPrefix\":\"generated-character-drafts\"")); + assert!(requests[ticket_index].contains(&format!( + "\"pathSegments\":[\"editor\",\"asset-canvas-references\",\"{}\",\"{}\",\"{}\"]", + project_id, draft.draft_id, input.generation_id + ))); + assert!(requests[confirm_index].contains(&expected_object_key)); + assert!(requests[submit_index] + .contains(&format!("\"sourceImageSrc\":\"{}\"", expected_object_key))); + assert!(!requests[submit_index].contains("sourceResourceId")); + + let manifest = execution.result.commit.manifest; + assert_eq!(manifest.assets.len(), 2); + let source = manifest + .assets + .iter() + .find(|asset| asset.id == "source-character") + .expect("source asset remains in manifest"); + let derived = manifest + .assets + .iter() + .find(|asset| asset.id == execution.result.commit.asset_id) + .expect("derived asset appended to manifest"); + assert_eq!(source.local_path, "assets/source.png"); + assert_ne!(derived.local_path, source.local_path); + assert!(derived + .source + .reference_resource_ids + .contains(&"local-asset:source-character".to_string())); + assert_eq!( + fs::read(directory.path().join(&source.local_path)).expect("read preserved source"), + source_png + ); + assert_eq!( + fs::read(directory.path().join(&derived.local_path)).expect("read derived image"), + result_png + ); + let ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read refine ledger") + .expect("refine ledger exists"); + assert_eq!(ledger.operation_id.as_deref(), Some("refine-operation")); + assert_eq!(ledger.resolved_reference_ids, vec![expected_object_key]); + assert!(ledger.reference_states.iter().all(|state| { + state.upload_bucket.is_none() + && state.upload_object_key.is_none() + && state.upload_completed + })); + } + + #[tokio::test] + async fn reference_ticket_failure_is_stage_specific_and_never_submits_generation() { + let project_id = "asset-canvas-ticket-failure-project"; + let project_name = "素材画布上传票据失败测试"; + let (directory, draft, _) = create_refine_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ticket failure server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("ticket failure address") + ); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().expect("accept ticket failure request"); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture ticket failure request"); + if request.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "remote-folder", + "label": project_name, + }]}}}), + ); + } else if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { + write_json( + &mut stream, + "400 Bad Request", + serde_json::json!({"error": {"code": "BAD_REQUEST"}}), + ); + } else { + panic!("unexpected ticket failure request: {request}"); + } + } + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": base_url, + "apiKey": "ticket-failure-private-api-key" + }}) + .to_string(), + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "这段提示词不得进入公开错误", + ); + + let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + .await + .err() + .expect("ticket failure must stop generation"); + server.join().expect("join ticket failure server"); + + assert_eq!(error, "参考资源上传凭证申请失败,未提交生成"); + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 3); + assert!(!requests.iter().any(|request| { + request.starts_with("POST /api/external/v1/editor/images/edits ") + || request.starts_with("POST /api/external/v1/editor/images/generations ") + })); + let ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ticket failure ledger") + .expect("ticket failure ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::Failed); + assert_eq!( + ledger.error_code.as_deref(), + Some("reference-ticket-failed") + ); + assert!(ledger.operation_id.is_none()); + assert!(ledger.request_body_json.is_none()); + let manifest = + current_asset_canvas_manifest(directory.path()).expect("read source manifest"); + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].id, "source-character"); + } + + #[test] + fn reference_preparation_error_codes_remain_stage_specific_and_sanitized() { + for (error, code, message) in [ + ( + ReferencePreparationError::MaterialInvalid, + "reference-material-invalid", + "参考资源读取或校验失败,未提交生成", + ), + ( + ReferencePreparationError::TicketFailed, + "reference-ticket-failed", + "参考资源上传凭证申请失败,未提交生成", + ), + ( + ReferencePreparationError::ObjectUploadFailed, + "reference-object-upload-failed", + "参考资源上传失败,未提交生成", + ), + ( + ReferencePreparationError::ConfirmFailed, + "reference-confirm-failed", + "参考资源对象确认失败,未提交生成", + ), + ] { + assert_eq!(error.code(), code); + assert_eq!(sanitized_generation_error(code), message); + } + assert_eq!( + ReferencePreparationError::from_http_error( + "创建参考资源上传凭证失败:HTTP 401", + ReferencePreparationError::TicketFailed, + ), + ReferencePreparationError::AuthenticationRequired + ); + } + #[tokio::test] async fn accepted_restart_uses_get_only_and_duplicate_recovery_does_not_redownload_or_recommit() { @@ -2941,7 +3390,9 @@ mod tests { let ticket = PrivateUploadTicket { host: "https://private-upload.provider.example.test/signed".to_string(), bucket: "stable-private-bucket".to_string(), - object_key: "asset-canvas-references/project/reference.png".to_string(), + object_key: + "generated-character-drafts/editor/asset-canvas-references/project/reference.png" + .to_string(), success_action_status: 204, form_fields: BTreeMap::from([ ( @@ -2974,7 +3425,9 @@ mod tests { let persisted = serde_json::to_string_pretty(&ledger).expect("serialize private ledger"); assert!(persisted.contains("stable-private-bucket")); - assert!(persisted.contains("asset-canvas-references/project/reference.png")); + assert!(persisted.contains( + "generated-character-drafts/editor/asset-canvas-references/project/reference.png" + )); for forbidden in [ "uploadTicket", "formFields", 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 7a8498b65..8b57131c7 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 @@ -12,6 +12,8 @@ const RESOURCE_EDIT_AUDIO_MAX_BYTES: usize = 64 * 1024 * 1024; const RESOURCE_EDIT_VIDEO_MAX_BYTES: usize = 128 * 1024 * 1024; const RESOURCE_EDIT_ROOT: &str = ".agent/resource-edits"; const RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; +const RESOURCE_EDIT_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; +const RESOURCE_EDIT_UPLOAD_NAMESPACE: &str = "resource-editor-references"; static RESOURCE_EDIT_LOCK: OnceLock> = OnceLock::new(); @@ -782,15 +784,12 @@ async fn request_resource_edit_upload_ticket( let response = client .post(format!("{api_base_url}/api/assets/direct-upload-tickets")) .bearer_auth(access_token) - .json(&serde_json::json!({ - "legacyPrefix": "resource-editor-references", - "pathSegments": [input.expected_project_id.as_str(), input.operation_id.as_str()], - "fileName": file_name, - "contentType": source.media_type, - "access": "private", - "maxSizeBytes": bytes.len(), - "successActionStatus": 204, - })) + .json(&resource_edit_upload_ticket_payload( + input, + source, + &file_name, + bytes.len(), + )) .send() .await .map_err(|_| "创建源资源上传凭证失败".to_string())?; @@ -847,6 +846,28 @@ async fn request_resource_edit_upload_ticket( }) } +fn resource_edit_upload_ticket_payload( + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + file_name: &str, + byte_length: usize, +) -> serde_json::Value { + serde_json::json!({ + "legacyPrefix": RESOURCE_EDIT_UPLOAD_LEGACY_PREFIX, + "pathSegments": [ + "editor", + RESOURCE_EDIT_UPLOAD_NAMESPACE, + input.expected_project_id.as_str(), + input.operation_id.as_str() + ], + "fileName": file_name, + "contentType": source.media_type, + "access": "private", + "maxSizeBytes": byte_length, + "successActionStatus": 204, + }) +} + async fn upload_resource_edit_source( ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, @@ -2042,6 +2063,46 @@ mod tests { ); } + #[test] + fn local_media_upload_ticket_uses_legal_private_editor_namespace() { + let directory = tempfile::tempdir().expect("create resource editor fixture"); + let operation_id = Uuid::new_v4().to_string(); + let request = input( + directory.path(), + operation_id.clone(), + LocalProjectResourceEditKind::Video, + "local-asset:video-1".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: "local-asset:video-1".to_string(), + source_path: Some("assets/source-video.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(vec![1, 2, 3]), + text: None, + source_asset: None, + source_version: None, + }; + + let payload = resource_edit_upload_ticket_payload(&request, &source, "source-video.mp4", 3); + assert_eq!(payload["legacyPrefix"], "generated-character-drafts"); + assert_eq!( + payload["pathSegments"], + serde_json::json!([ + "editor", + "resource-editor-references", + PROJECT_ID, + operation_id + ]) + ); + assert_eq!(payload["fileName"], "source-video.mp4"); + assert_eq!(payload["contentType"], "video/mp4"); + assert_eq!(payload["access"], "private"); + assert_eq!(payload["maxSizeBytes"], 3); + assert_eq!(payload["successActionStatus"], 204); + } + #[test] fn staging_replay_accepts_identical_bytes_and_rejects_conflicting_bytes() { let directory = tempfile::tempdir().expect("create resource editor fixture"); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1406f703c..e4ea5aa45 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 客户端参考媒体直传复用已授权私有前缀 + +- 根因:客户端素材画布和全类型资源编辑把内部用途目录 `asset-canvas-references`、`resource-editor-references` 直接作为 `legacyPrefix`;api-server 只接受 `platform-oss` 权威白名单,因此请求在票据阶段返回 `400`,OSS 上传、对象确认、生成提交和扣费都没有发生。 +- 存储决策:继续复用合法私有前缀 `generated-character-drafts`,不扩大 legacy 白名单、不增加平行上传接口。图片画布使用 `editor/asset-canvas-references///`,全类型资源编辑使用 `editor/resource-editor-references//` 作为 `pathSegments`;生成请求只消费 confirm 后的稳定 `objectKey`。 +- 错误边界:图片参考资源准备分别投影 `reference-material-invalid`、`reference-ticket-failed`、`reference-object-upload-failed`、`reference-confirm-failed`;`401/403` 继续收敛为 `authentication-required`。这些错误只返回安全阶段,不暴露 ticket host、formFields、policy、signature、Token、API Key 或 Provider 内部正文;取得稳定对象前必须保持 `operationId` 和生成 request body 为空。 +- 验证:客户端端到端夹具锁定 ticket → OSS form POST → object confirm → image edit POST 顺序,并断言源图片文件和 manifest asset 保留、派生图片追加;失败夹具锁定票据失败后零生成 POST。api-server 契约测试锁定精确前缀、目录和私有对象 key。 +- 关联:`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`、`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`。 + ## 2026-08-10 客户端现有资源编辑扩展到全部现役类型 - 产品入口:继续禁用“新增资源”,资源聚焦态的 manifest asset、已完成任务产物、上传附件、Agent 文本回执和项目版本统一显示“编辑资源”。静态图片复用 refine 图片画布,其他类型进入同一资源编辑壳,不建立平行资源总览。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f7086b3e7..4fe20ce95 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4501,6 +4501,14 @@ - 处理:ticket 结构不实现 Serialize/Deserialize,host/formFields 只在本次内存调用中使用。账本在上传前只保存稳定 bucket/objectKey;重启先用这组身份调用 object confirm,确认成功后只保留 objectKey/assetObjectId 并清掉上传中间态。账本测试必须直接序列化完整 ledger,扫描 Provider URL、Authorization、policy、signature、API Key 和 ticket 字段名。 - 验证:运行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml private_generation_ledger_never_serializes_upload_credentials_or_provider_url`,并继续检查公开草稿、manifest、事件和普通错误不含 prompt、operationId、Key、绝对路径或媒体正文。 +## 客户端内部用途目录不能直接作为 legacyPrefix(2026-08-10) + +- 现象:本地图片精修或视频、音频等全类型资源编辑点击生成后立即提示参考资源上传失败;私有账本的 `uploadBucket/uploadObjectKey/operationId/requestBodyJson` 全为空,服务端也没有 OSS、confirm、生成或扣费记录。 +- 原因:direct-upload ticket 的 `legacyPrefix` 不是任意业务目录,而是 `platform-oss::LegacyAssetPrefix` 的权威白名单值。把 `asset-canvas-references` 或 `resource-editor-references` 直接放在该字段会被 api-server 在签名之前以 `400` 拒绝;客户端若把票据、OSS 和 confirm 全折叠成一个错误码,还会掩盖真正失败阶段。 +- 处理:客户端编辑器统一使用合法私有 `legacyPrefix=generated-character-drafts`,把业务用途放入 `pathSegments`:图片画布为 `editor/asset-canvas-references///`,全类型资源编辑为 `editor/resource-editor-references//`。仍严格执行 ticket → OSS form POST → object confirm,只有 confirm 返回自洽稳定 `objectKey/assetObjectId` 后才允许提交生成;不要为内部目录扩白名单或新建上传接口。图片路径按本地校验、票据、对象上传、对象确认分别使用安全错误码,票据材料继续只驻留内存。 +- 验证:客户端端到端测试必须断言 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`。 + ## prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05) - 现象:事务依次安装 before/after 快照后才写 journal;若进程在首个快照、全部快照或 journal 已写但 ledger 未写时退出,重启扫描看到 transaction 目录却无法进入原先只覆盖 prepared 之后的恢复状态机,可能留下孤儿目录或阻塞项目后续提交。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index e326273a2..242daadb6 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -954,6 +954,8 @@ confirmation-required - 普通客户生成使用 `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 个。 +- 客户端参考媒体直传固定复用 `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;其它未知或对账状态必须保留原身份。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index d990de7d2..cfd70d42e 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -1394,6 +1394,72 @@ mod tests { assert!(payload["data"]["upload"].get("publicUrl").is_none()); } + #[tokio::test] + async fn direct_upload_ticket_accepts_asset_canvas_reference_namespace() { + let config = AppConfig { + oss_bucket: Some("genarrative-assets".to_string()), + oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), + oss_access_key_id: Some("test-access-key-id".to_string()), + oss_access_key_secret: Some("test-access-key-secret".to_string()), + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138122", "sess_asset_canvas_reference_ticket") + .await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/direct-upload-tickets") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1") + .body(Body::from( + json!({ + "legacyPrefix": "generated-character-drafts", + "pathSegments": [ + "editor", + "asset-canvas-references", + "project-123", + "draft-456", + "generation-789" + ], + "fileName": "reference-sha256.png", + "contentType": "image/png", + "access": "private", + "maxSizeBytes": 4096, + "successActionStatus": 204 + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + let expected_object_key = "generated-character-drafts/editor/asset-canvas-references/project-123/draft-456/generation-789/reference-sha256.png"; + assert_eq!(payload["data"]["upload"]["objectKey"], expected_object_key); + assert_eq!( + payload["data"]["upload"]["formFields"]["key"], + expected_object_key + ); + assert_eq!(payload["data"]["upload"]["access"], "private"); + assert_eq!(payload["data"]["upload"]["successActionStatus"], 204); + assert_eq!(payload["data"]["upload"]["maxSizeBytes"], 4096); + } + #[tokio::test] async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() { let config = AppConfig {