From ae2d1a3a21c443ec0326a65336d4ca96bcc9ca42 Mon Sep 17 00:00:00 2001 From: Linghong Date: Sat, 3 Oct 2026 14:48:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=87=86=E5=A4=87=E4=BE=9D=E8=B5=96=E5=85=A8=E9=87=8F=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93=E7=9A=84=E9=97=AE=E9=A2=98=20(#592)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复已有项目和素材目录绑定时,AGC 生成准备仍读取完整账户素材库、导致无关素材读取失败阻断生成的问题。 - 新增站内与 External v1 的单目录 GET,按主键读取并校验账号归属;复用目录 DTO,不读取素材列表或创建默认目录。 - 已有 binding 和 partial 恢复改为确认目标目录;首次建绑直接沿用稳定幂等创建,不再读取全库。 - 目录缺失、越权、超时、响应身份不符或会话切换时停止,保留恢复证据,不自动重建、不换键重发生成;已有生成账本继续沿用冻结快照恢复。 - 既有素材库与目录创建、修改、删除 API 合同保持不变,持久化表结构不变。同步 OpenAPI、生成绑定及技术文档。 验证: - AGC Rust:目录绑定 7 项、生成 94 项、资源编辑 60 项、抠图 5 项通过(目录绑定用例包含在生成组中)。 - api-server:External Editor 30 项、受保护路由矩阵 3 项通过。 - 真实隔离 SpacetimeDB:目录读取成功、空目录、缺失、越权、删除后缺失、只读不变,以及既有幂等/并发/孤儿检查通过。 - smoke 脚本测试 4 项通过;module 编译、生成绑定、87 张表 schema guard、DDD、编码、文档索引、定向 Rust 格式与 diff 检查通过。 - 已比较 OpenAPI:除新增单目录 GET 外,原有定义逐项保持一致。 验证限制:未完成独立 API `/healthz` 联调,本地隔离数据库与授权配置未就绪;启动尝试已停止并清理。未运行全量测试或真实付费生成。 发布顺序:先发布后端模块与 API,再发布新客户端;不提供全库回退。#574 的素材分页、筛选及按素材 ID 读取不在本 PR 范围内。 Closes #550 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/592 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src-tauri/src/agent/direct_tools_mcp.rs | 11 +- .../src/agent/generation/canvas_generation.rs | 530 +++++++++++++----- .../src-tauri/src/project/resource_editor.rs | 36 +- .../background_removal_tests.rs | 10 +- .../genarrative-external-v1.openapi.json | 40 ++ .../shared-memory/development-workflow.md | 2 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + .../spacetime-editor-idempotency-smoke.mjs | 97 +++- .../crates/api-server/src/editor_project.rs | 25 +- .../api-server/src/external_editor_api.rs | 106 +++- .../src/external_mcp/semantic/tests.rs | 4 +- .../api-server/src/modules/editor_project.rs | 14 +- .../api-server/src/modules/external_api.rs | 15 +- .../spacetime-client/src/active/mapper.rs | 2 +- .../src/active/mapper/editor_project.rs | 15 + .../spacetime-client/src/editor_project.rs | 22 + .../spacetime-client/src/module_bindings.rs | 4 + .../editor_asset_folder_get_input_type.rs | 16 + ...ditor_asset_folder_and_return_procedure.rs | 59 ++ .../src/editor_project_storage.rs | 28 + 21 files changed, 852 insertions(+), 189 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 6e2d51cb4..50ff3abf5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -3464,14 +3464,16 @@ mod tests { "title": "工具链远端画布" }]}}), ); - } else if route.starts_with("/api/editor/assets/library") { + } else if request_line.starts_with(&format!( + "GET /api/editor/assets/folders/{TOOL_CHAIN_ASSET_FOLDER_ID} " + )) { tool_chain_write_json( &mut stream, "200 OK", - json!({"data": {"library": {"folders": [{ + json!({"data": {"folder": { "folderId": TOOL_CHAIN_ASSET_FOLDER_ID, "label": "工具链远端目录" - }]}}}), + }}}), ); } else if request_line.starts_with(expected_submission) { tool_chain_write_json( @@ -4017,6 +4019,9 @@ mod tests { ); assert_eq!(requests.len(), 6, "{requests:?}"); + assert!(requests.iter().any(|request| request.starts_with(&format!( + "GET /api/editor/assets/folders/{TOOL_CHAIN_ASSET_FOLDER_ID} " + )))); let submission = requests .iter() .find(|request| request.starts_with("POST /api/editor/images/edits ")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index dc68f3f86..245491074 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1555,69 +1555,103 @@ pub(crate) async fn prepare_external_canvas_generation_context( write_external_canvas_binding_partial(root, &mut partial)?; } - let asset_folder_id = if let Some(asset_folder_id) = partial.remote_asset_folder_id.clone() { - asset_folder_id - } else { - let library_payload = external_editor_json_request( + let reusable_folder_id = partial.remote_asset_folder_id.clone().or_else(|| { + existing_binding + .as_ref() + .filter(|binding| binding.remote_project_id == project_id) + .map(|binding| binding.remote_asset_folder_id.clone()) + }); + let asset_folder_id = if let Some(asset_folder_id) = reusable_folder_id { + validate_external_canvas_binding_partial_id( + &asset_folder_id, + "External Editor remote assetFolderId", + )?; + let folder_route = format!( + "/api/external/v1/editor/assets/folders/{}", + percent_encode_query_component(&asset_folder_id) + ); + access.validate_frozen_session()?; + let response = crate::http_client::with_agc_main_site_marker( client .get(format!( "{}{}", access.api_base_url(), - access.api_route("/api/external/v1/editor/assets/library") + access.api_route(&folder_route) )) .bearer_auth(access.bearer_token()), - "读取外部素材库", + ) + .send() + .await + .map_err(|error| { + format!( + "读取绑定素材目录失败:{}", + describe_external_request_failure(&error) + ) + })?; + access.validate_frozen_session()?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err("绑定素材目录不存在,无法继续生成".to_string()); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); + return Err(format_external_http_error( + "读取绑定素材目录", + status, + &body, + )); + } + let payload = response + .json::() + .await + .map_err(|error| { + format!( + "解析绑定素材目录响应失败:{}", + describe_external_request_failure(&error) + ) + })?; + access.validate_frozen_session()?; + let response_folder_id = external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "绑定素材目录响应缺少 folder.folderId".to_string())?; + if response_folder_id != asset_folder_id { + return Err("绑定素材目录响应身份与请求不一致".to_string()); + } + asset_folder_id + } else { + access.validate_frozen_session()?; + let payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/folders") + )) + .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-folder-{binding_key}"), + ) + .json(&serde_json::json!({ "label": canvas_name })), + "创建外部素材库目录", ) .await?; - access.validate_frozen_session()?; - 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())?; - match existing_binding.as_ref().and_then(|binding| { - (binding.remote_project_id == project_id - && folders.iter().any(|folder| { - json_string_field(folder, "folderId").as_deref() - == Some(binding.remote_asset_folder_id.as_str()) - })) - .then(|| binding.remote_asset_folder_id.clone()) - }) { - Some(asset_folder_id) => asset_folder_id, - None => { - access.validate_frozen_session()?; - let payload = external_editor_json_request( - client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/editor/assets/folders") - )) - .bearer_auth(access.bearer_token()) - .header( - "Idempotency-Key", - format!("game-creator-folder-{binding_key}"), - ) - .json(&serde_json::json!({ "label": canvas_name })), - "创建外部素材库目录", - ) - .await?; - let post_response_session = access.validate_frozen_session(); - let asset_folder_id = external_editor_response_data(&payload) - .get("folder") - .and_then(|folder| json_string_field(folder, "folderId")) - .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())?; - validate_external_canvas_binding_partial_id( - &asset_folder_id, - "External Editor remote assetFolderId", - )?; - partial.remote_asset_folder_id = Some(asset_folder_id.clone()); - partial.updated_at = unix_timestamp(); - write_external_canvas_binding_partial(root, &mut partial)?; - post_response_session?; - asset_folder_id - } - } + let post_response_session = access.validate_frozen_session(); + let asset_folder_id = external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())?; + validate_external_canvas_binding_partial_id( + &asset_folder_id, + "External Editor remote assetFolderId", + )?; + partial.remote_asset_folder_id = Some(asset_folder_id.clone()); + partial.updated_at = unix_timestamp(); + write_external_canvas_binding_partial(root, &mut partial)?; + post_response_session?; + asset_folder_id }; validate_external_canvas_binding_partial_id( &asset_folder_id, @@ -8734,6 +8768,16 @@ mod canvas_generation_tests { Err(error) => panic!("accept standalone generation request: {error}"), }; let request = read_test_http_request(&mut stream); + if request + .starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": {"folderId": "manual-test-assets"}}}), + ); + continue; + } request_sender .send(request) .expect("capture standalone generation request"); @@ -8914,12 +8958,14 @@ mod canvas_generation_tests { }), ); false - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + } else if request + .starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") + { write_test_json_response( &mut stream, "200 OK", &serde_json::json!({ - "data": {"library": {"folders": [{"folderId": "manual-test-assets"}]}} + "data": {"folder": {"folderId": "manual-test-assets"}} }), ); false @@ -9289,15 +9335,15 @@ mod canvas_generation_tests { ); return; } - if request.starts_with("GET /api/external/v1/editor/assets/library ") { + if request.starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") { write_test_json_response( stream, "200 OK", &serde_json::json!({ - "data": {"library": {"folders": [{ + "data": {"folder": { "folderId": "manual-test-assets", "label": "并发手工生成素材", - }]}} + }} }), ); return; @@ -9726,6 +9772,229 @@ mod canvas_generation_tests { ); } + #[tokio::test] + async fn external_canvas_context_confirms_bound_and_partial_folders_without_library_or_rebuild() + { + for use_partial in [false, true] { + for (status, returned_id, expected_error) in [ + ("200 OK", "manual-test-assets", None), + ("404 Not Found", "", Some("绑定素材目录不存在")), + ("401 Unauthorized", "", Some("HTTP 401")), + ("403 Forbidden", "", Some("HTTP 403")), + ("500 Internal Server Error", "", Some("HTTP 500")), + ("timeout", "", Some("请求超时")), + ("200 OK", "other-folder", Some("响应身份与请求不一致")), + ] { + let temporary = tempfile::tempdir().expect("create folder confirmation project"); + let root = temporary.path(); + init_local_game_project_at(root, "folder-confirmation-local", "目录身份确认测试") + .expect("initialize folder confirmation project"); + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind folder confirmation fixture"); + listener + .set_nonblocking(true) + .expect("set folder fixture nonblocking"); + let base_url = + format!("http://{}", listener.local_addr().expect("fixture address")); + let access = ExternalEditorBindingAccess::for_developer( + &base_url, + "folder-confirmation-key", + ) + .expect("create folder confirmation access"); + install_test_external_project_binding(root, &base_url, "folder-confirmation-key"); + let principal = + external_editor_binding_principal(&access).expect("folder principal"); + let binding = read_external_editor_project_binding_at( + root, + "folder-confirmation-local", + &principal, + ) + .expect("read original binding") + .expect("original binding exists"); + if !use_partial { + remove_external_canvas_binding_partial(root, &binding.binding_key_sha256) + .expect("prepare completed binding"); + } + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut requests = Vec::new(); + loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept folder confirmation request: {error}"), + }; + let request = read_test_http_request(&mut stream); + requests.push(request.clone()); + if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": [{"projectId": "manual-test-canvas"}]}}), + ); + } else if request.starts_with( + "GET /api/external/v1/editor/assets/folders/manual-test-assets ", + ) { + if status == "timeout" { + std::thread::sleep(Duration::from_millis(200)); + continue; + } + // Empty folders require no assets or library snapshot to be usable. + write_test_json_response( + &mut stream, + status, + &serde_json::json!({"data": {"folder": {"folderId": returned_id}}, "error": {"message": "folder fixture rejection"}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") + { + // An unavailable, slow account library cannot block identity confirmation. + std::thread::sleep(Duration::from_millis(200)); + } else { + write_test_json_response( + &mut stream, + "500 Internal Server Error", + &serde_json::json!({"error": {"message": "unexpected folder rebuild"}}), + ); + } + } + requests + }); + let client = reqwest::Client::builder() + .timeout(if status == "timeout" { + Duration::from_millis(100) + } else { + Duration::from_secs(2) + }) + .build() + .expect("build folder confirmation client"); + let result = + prepare_external_canvas_generation_context(root, &client, &access).await; + stop_sender + .send(()) + .expect("stop folder confirmation fixture"); + let requests = server.join().expect("join folder confirmation fixture"); + if let Some(expected_error) = expected_error { + let error = + result.expect_err("directory confirmation failure must stop preparation"); + assert!( + error.contains(expected_error), + "partial={use_partial}, status={status}: {error}" + ); + assert_eq!( + read_external_editor_project_binding_at( + root, + "folder-confirmation-local", + &principal + ) + .expect("reread unchanged binding"), + Some(binding) + ); + } else { + assert_eq!( + result + .expect("empty bound directory is usable") + .asset_folder_id, + "manual-test-assets" + ); + } + assert_eq!( + requests.len(), + if use_partial { 1 } else { 2 }, + "{requests:?}" + ); + assert!( + requests.iter().all(|request| request + .starts_with("GET /api/external/v1/editor/projects?view=summary ") + || request.starts_with( + "GET /api/external/v1/editor/assets/folders/manual-test-assets " + )), + "{requests:?}" + ); + } + } + } + + #[tokio::test] + async fn external_canvas_context_stops_when_account_changes_during_folder_confirmation() { + let temporary = tempfile::tempdir().expect("create folder account switch project"); + let root = temporary.path(); + init_local_game_project_at(root, "folder-switch-local", "目录确认切号测试") + .expect("initialize folder switch project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind folder switch fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let _session = crate::platform_session::install_test_platform_session( + "folder-user-a", + "folder-token-a", + &base_url, + ); + let (_, _, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("freeze folder account A"); + let access = + ExternalEditorBindingAccess::for_platform(frozen.as_ref().expect("frozen session")) + .expect("create frozen folder access"); + let principal = + external_editor_binding_principal(&access).expect("folder switch principal"); + let binding = new_external_editor_project_binding( + "folder-switch-local", + &principal, + "folder-switch-project", + "folder-switch-folder", + unix_timestamp(), + ) + .expect("create account A binding"); + write_external_editor_project_binding_at(root, &binding).expect("write account A binding"); + let server = std::thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..2 { + let (mut stream, _) = listener.accept().expect("accept folder switch request"); + let request = read_test_http_request(&mut stream); + assert_eq!( + test_request_header(&request, "authorization"), + "Bearer folder-token-a" + ); + if request.starts_with("GET /api/editor/projects?view=summary ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": [{"projectId": "folder-switch-project"}]}}), + ); + } else { + assert!( + request.starts_with("GET /api/editor/assets/folders/folder-switch-folder ") + ); + install_platform_session("folder-user-b", "folder-token-b", &base_url, 2, 2) + .expect("switch account during folder confirmation"); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": {"folderId": "folder-switch-folder"}}}), + ); + } + requests.push(request); + } + requests + }); + let error = + prepare_external_canvas_generation_context(root, &reqwest::Client::new(), &access) + .await + .expect_err("account switch must stop directory confirmation"); + assert!(error.contains("账号") || error.contains("会话"), "{error}"); + assert_eq!(server.join().expect("join folder switch fixture").len(), 2); + assert_eq!( + read_external_editor_project_binding_at(root, "folder-switch-local", &principal) + .expect("read frozen account binding"), + Some(binding) + ); + } + #[tokio::test] async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { let temporary = tempfile::tempdir().expect("create account binding project"); @@ -9738,8 +10007,7 @@ mod canvas_generation_tests { let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { let mut project_created = BTreeMap::::new(); - let mut folder_created = BTreeMap::::new(); - for _ in 0..10 { + for _ in 0..8 { let (mut stream, _) = listener.accept().expect("accept binding request"); let request = read_test_http_request(&mut stream); let account = match test_request_header(&request, "authorization") { @@ -9762,18 +10030,14 @@ mod canvas_generation_tests { serde_json::json!({"data": {"project": { "projectId": format!("remote-project-{account}") }}}) - } else if request.starts_with("GET /api/editor/assets/library ") { - let folders = if folder_created.get(account).copied().unwrap_or(false) { - vec![serde_json::json!({ - "folderId": format!("remote-folder-{account}"), - "label": "服务端目录标题不参与绑定", - })] - } else { - Vec::new() - }; - serde_json::json!({"data": {"library": {"folders": folders}}}) + } else if request.starts_with(&format!( + "GET /api/editor/assets/folders/remote-folder-{account} " + )) { + serde_json::json!({"data": {"folder": { + "folderId": format!("remote-folder-{account}"), + "label": "服务端目录标题不参与绑定", + }}}) } else if request.starts_with("POST /api/editor/assets/folders ") { - folder_created.insert(account.to_string(), true); serde_json::json!({"data": {"folder": { "folderId": format!("remote-folder-{account}") }}}) @@ -9848,7 +10112,7 @@ mod canvas_generation_tests { context_a.asset_folder_id ); let requests = request_receiver.try_iter().collect::>(); - assert_eq!(requests.len(), 10); + assert_eq!(requests.len(), 8); assert_eq!( requests .iter() @@ -9872,7 +10136,6 @@ mod canvas_generation_tests { project_created: bool, folder_created: bool, project_get_waiters: usize, - folder_get_waiters: usize, } let temporary = tempfile::tempdir().expect("create concurrent binding project"); @@ -9936,25 +10199,9 @@ mod canvas_generation_tests { state.project_created = true; ready.notify_all(); serde_json::json!({"data": {"project": {"projectId": "single-project"}}}) - } else if request.starts_with("GET /api/editor/assets/library ") { - let (state_lock, ready) = &*state; - let mut state = state_lock.lock().expect("lock folder fixture state"); - if !state.folder_created { - state.folder_get_waiters += 1; - ready.notify_all(); - if state.folder_get_waiters < 2 { - let (next, _) = ready - .wait_timeout(state, Duration::from_millis(150)) - .expect("wait for concurrent folder GET"); - state = next; - } - } - let folders = state.folder_created.then(|| { - vec![serde_json::json!({"folderId": "single-folder"})] - }); - serde_json::json!({ - "data": {"library": {"folders": folders.unwrap_or_default()}} - }) + } else if request.starts_with("GET /api/editor/assets/folders/single-folder ") { + assert!(state.0.lock().expect("lock folder fixture state").folder_created); + serde_json::json!({"data": {"folder": {"folderId": "single-folder"}}}) } else if request.starts_with("POST /api/editor/assets/folders ") { let (state_lock, ready) = &*state; let mut state = state_lock.lock().expect("lock folder create state"); @@ -10041,8 +10288,8 @@ mod canvas_generation_tests { let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - let mut failed_library_once = false; - for _ in 0..5 { + let mut failed_folder_once = false; + for _ in 0..4 { let (mut stream, _) = listener.accept().expect("accept project partial request"); let request = read_test_http_request(&mut stream); request_sender @@ -10062,21 +10309,15 @@ mod canvas_generation_tests { "projectId": "partial-remote-project" }}}), ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") - && !failed_library_once + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") + && !failed_folder_once { - failed_library_once = true; + failed_folder_once = true; write_test_json_response( &mut stream, "500 Internal Server Error", &serde_json::json!({"error": {"code": "fixture-failure"}}), ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_test_json_response( - &mut stream, - "200 OK", - &serde_json::json!({"data": {"library": {"folders": []}}}), - ); } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { write_test_json_response( &mut stream, @@ -10097,7 +10338,7 @@ mod canvas_generation_tests { let first_error = prepare_external_canvas_generation_context(root, &client, &access) .await .expect_err("failure after project creation must be surfaced"); - assert!(first_error.contains("读取外部素材库"), "{first_error}"); + assert!(first_error.contains("创建外部素材库目录"), "{first_error}"); let manifest = read_existing_manifest_for_project(root).expect("read partial manifest"); let principal = external_editor_binding_principal(&access).expect("partial principal"); let binding_key = @@ -10159,7 +10400,8 @@ mod canvas_generation_tests { |request| request.starts_with("POST /api/external/v1/editor/assets/folders ") ) .count(), - 1 + 2, + "folder creation retries must reuse the frozen request" ); } @@ -10176,7 +10418,7 @@ mod canvas_generation_tests { let server = std::thread::spawn(move || { let mut project_posts = 0_u8; let mut folder_posts = 0_u8; - for _ in 0..8 { + for _ in 0..6 { let (mut stream, _) = listener.accept().expect("accept frozen binding request"); let request = read_test_http_request(&mut stream); request_sender @@ -10202,12 +10444,6 @@ mod canvas_generation_tests { "projectId": "frozen-remote-project" }}}), ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_test_json_response( - &mut stream, - "200 OK", - &serde_json::json!({"data": {"library": {"folders": []}}}), - ); } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { folder_posts += 1; if folder_posts == 1 { @@ -10257,6 +10493,10 @@ mod canvas_generation_tests { .filter(|request| request.starts_with("POST /api/external/v1/editor/projects ")) .collect::>(); assert_eq!(project_posts.len(), 2); + assert_eq!( + test_request_header(project_posts[0], "idempotency-key"), + test_request_header(project_posts[1], "idempotency-key") + ); assert!(project_posts .iter() .all(|request| request.contains("\"title\":\"首次画布名称\""))); @@ -10268,6 +10508,10 @@ mod canvas_generation_tests { .filter(|request| request.starts_with("POST /api/external/v1/editor/assets/folders ")) .collect::>(); assert_eq!(folder_posts.len(), 2); + assert_eq!( + test_request_header(folder_posts[0], "idempotency-key"), + test_request_header(folder_posts[1], "idempotency-key") + ); assert!(folder_posts .iter() .all(|request| request.contains("\"label\":\"首次画布名称\""))); @@ -10307,24 +10551,27 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture folder partial request"); - let response = - if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") { - serde_json::json!({"data": {"projects": []}}) - } else if request.starts_with("POST /api/external/v1/editor/projects ") { - serde_json::json!({"data": {"project": { - "projectId": "folder-partial-project" - }}}) - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - serde_json::json!({"data": {"library": {"folders": []}}}) - } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { - fs::create_dir_all(&blocked_binding_path) - .expect("block final binding write after folder creation"); - serde_json::json!({"data": {"folder": { - "folderId": "folder-partial-folder" - }}}) - } else { - panic!("unexpected folder partial request: {request}"); - }; + let response = if request + .starts_with("GET /api/external/v1/editor/projects?view=summary ") + { + serde_json::json!({"data": {"projects": []}}) + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + serde_json::json!({"data": {"project": { + "projectId": "folder-partial-project" + }}}) + } else if request.starts_with( + "GET /api/external/v1/editor/assets/folders/folder-partial-folder ", + ) { + serde_json::json!({"data": {"folder": {"folderId": "folder-partial-folder"}}}) + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + fs::create_dir_all(&blocked_binding_path) + .expect("block final binding write after folder creation"); + serde_json::json!({"data": {"folder": { + "folderId": "folder-partial-folder" + }}}) + } else { + panic!("unexpected folder partial request: {request}"); + }; write_test_json_response(&mut stream, "200 OK", &response); } }); @@ -10333,7 +10580,6 @@ mod canvas_generation_tests { .await .expect_err("final binding write must fail"); assert!(first_error.contains("必须是普通文件"), "{first_error}"); - server.join().expect("join folder partial fixture"); let partial = read_external_canvas_binding_partial(root, &binding_key, &manifest.project_id) .expect("read folder partial") @@ -10350,10 +10596,20 @@ mod canvas_generation_tests { let recovered = prepare_external_canvas_generation_context(root, &client, &access) .await - .expect("recover folder partial without remote requests"); + .expect("recover folder partial by confirming its directory"); + server.join().expect("join folder partial fixture"); assert_eq!(recovered.project_id, "folder-partial-project"); assert_eq!(recovered.asset_folder_id, "folder-partial-folder"); let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with( + "GET /api/external/v1/editor/assets/folders/folder-partial-folder " + )) + .count(), + 1 + ); assert_eq!( requests .iter() @@ -15730,15 +15986,15 @@ mod canvas_generation_tests { ); return; } - if request.starts_with("GET /api/external/v1/editor/assets/library ") { + if request.starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") { write_test_json_response( stream, "200 OK", &serde_json::json!({ - "data": {"library": {"folders": [{ + "data": {"folder": { "folderId": "manual-test-assets", "label": "场景生成素材", - }]}} + }} }), ); return; 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 fd03fa539..4aa2ec76e 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 @@ -6350,15 +6350,17 @@ mod tests { "projectId": "source-binding-project" }]}}), ); - } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") - || request_line.starts_with("GET /api/editor/assets/library ") + } else if request_line.starts_with( + "GET /api/external/v1/editor/assets/folders/source-binding-folder ", + ) || request_line + .starts_with("GET /api/editor/assets/folders/source-binding-folder ") { write_json( &mut stream, "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ + serde_json::json!({"data": {"folder": { "folderId": "source-binding-folder" - }]}}}), + }}}), ); } else if request_line .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") @@ -7047,7 +7049,7 @@ mod tests { "generated/owner-b-source.{}", if is_image { "png" } else { "mp4" } ); - let request_count = if is_image { 6 } else { 7 }; + let request_count = 6; let server_upload_url = upload_url.clone(); let server_object_key = owner_b_object_key.clone(); let (sender, receiver) = mpsc::channel(); @@ -7082,18 +7084,12 @@ mod tests { serde_json::json!({"data": {"project": {"projectId": "owner-b-project"}}}), ); } else if request_line - .starts_with("GET /api/external/v1/editor/assets/library ") + .starts_with("GET /api/external/v1/editor/assets/folders/owner-b-folder ") { write_json( &mut stream, "200 OK", - if is_image { - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "owner-b-folder" - }]}}}) - } else { - serde_json::json!({"data": {"library": {"folders": []}}}) - }, + serde_json::json!({"data": {"folder": {"folderId": "owner-b-folder"}}}), ); } else if request_line .starts_with("POST /api/external/v1/editor/assets/folders ") @@ -7564,7 +7560,9 @@ mod tests { assert!(requests.iter().all(|request| { let line = request.lines().next().unwrap_or_default(); line.starts_with("GET /api/external/v1/editor/projects?view=summary ") - || line.starts_with("GET /api/external/v1/editor/assets/library ") + || line.starts_with( + "GET /api/external/v1/editor/assets/folders/source-binding-folder ", + ) })); } @@ -9743,7 +9741,7 @@ mod tests { let server_generated_video = generated_video.clone(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for request_index in 0..11 { + for request_index in 0..10 { let mut stream = accept_resource_editor_fixture_connection( &listener, "External video fixture", @@ -9769,12 +9767,6 @@ mod tests { "projectId": "external-video-project" }}}), ); - } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": []}}}), - ); } else if request_line.starts_with("POST /api/external/v1/editor/assets/folders ") { write_json( &mut stream, @@ -9892,7 +9884,7 @@ mod tests { .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(), 11); + assert_eq!(requests.len(), 10); assert!(requests.iter().all(|request| { !request.starts_with("POST /api/assets/") && !request.starts_with("GET /api/runtime/external-generation/") diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index 870b7773c..c15fb2938 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -400,13 +400,15 @@ fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool serde_json::json!({"data": {"projects": [{"projectId": REMOTE_PROJECT_ID}]}}), ); true - } else if line.starts_with("GET /api/external/v1/editor/assets/library ") - || line.starts_with("GET /api/editor/assets/library ") - { + } else if line.starts_with(&format!( + "GET /api/external/v1/editor/assets/folders/{REMOTE_FOLDER_ID} " + )) || line.starts_with(&format!( + "GET /api/editor/assets/folders/{REMOTE_FOLDER_ID} " + )) { write_json( stream, "200 OK", - serde_json::json!({"data": {"library": {"folders": [{"folderId": REMOTE_FOLDER_ID}]}}}), + serde_json::json!({"data": {"folder": {"folderId": REMOTE_FOLDER_ID}}}), ); true } else { diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 63a51cbac..aea0588eb 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -786,6 +786,46 @@ } }, "/api/external/v1/editor/assets/folders/{folderId}": { + "get": { + "tags": ["Editor Assets"], + "operationId": "getEditorAssetFolder", + "summary": "按 ID 读取当前账号素材文件夹", + "description": "要求 editor:asset scope。仅按 folderId 读取目录并校验当前账号归属,不读取素材列表或创建默认目录;空目录也可读取。目录不存在返回 404,无权访问返回 403。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FolderId" + } + ], + "responses": { + "200": { + "description": "素材文件夹", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, "patch": { "tags": ["Editor Assets"], "operationId": "updateEditorAssetFolder", diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 182e4656a..c5aa747b5 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -53,6 +53,8 @@ ## 验证路由 +AGC 生成准备只通过单目录 GET 确认当前账号绑定,不能重新依赖完整素材库,也不能把目录查询失败当成首次无绑定而创建目录。目录删除时保留证据并停止;`prepared / accepted / legacy-completed` 生成账本继续用冻结快照恢复。该查询同时覆盖站内会话与 External v1,后端新增接口先发布,旧 API 保持兼容;详见 AGC 实施计划和后端数据契约。 + Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。 AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 70bbbd2fe..4faeabece 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -946,6 +946,10 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 - External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。 - binding 的 lookup key 与稳定 payload 内容指纹分开校验;远端 project/folder/resource/object ID、object key 或尺寸被单独改写时必须失败关闭。同一本地项目和 principal 的首次项目 binding 建立必须串行化覆盖“复读 binding → 必要的远端创建 → 原子安装 binding”,不得因并发任务创建孤儿画布。External v1 项目、素材目录和项目资源创建请求携带由 binding key 派生的稳定 `Idempotency-Key`;服务端在 owner 与接口命名空间内生成稳定 ID,并允许同键同正文安全重试,使远端响应后、本地 sidecar 落盘前崩溃也不会重复创建。 +- 素材生成准备的目录确认采用单目录读取:站内 `GET /api/editor/assets/folders/{folderId}` 与开发者 `GET /api/external/v1/editor/assets/folders/{folderId}` 返回现有 `{folder: ...}` 数据结构,后端按目录主键读取并校验 owner,不读取账户素材、不创建默认目录、不执行媒体修复。External 入口要求 `editor:asset` scope;缺失目录返回 404,越权返回 403,未认证返回 401。原素材库快照及目录创建、修改、删除接口的合同不变;不修改持久化表结构。先部署新增后端接口,再发布依赖该接口的客户端;新客户端不回退完整素材库。 +- 已有当前账号项目 binding 或恢复 partial 的目录身份时,在新的生成准备中按该 ID 确认目录并核对响应 ID;合法空目录也可继续。首次没有可复用目录身份时直接沿用稳定幂等创建和冻结正文,不先列举素材库,不按同名认领目录。partial 继续只承载中间恢复进度,成功写入完整 binding 后删除;既有生成账本的 `prepared / accepted / legacy-completed` 恢复仍使用冻结快照,不重新查询或创建目录。 +- 单目录查询超时、断网、服务异常、鉴权失效、越权或会话切换时停止准备并保留现有 binding / 恢复证据,不解释为目录缺失、不自动 POST 新目录。明确 404 时提示绑定目录不存在并停止,不用原键复活目录、不换键重建或重发生成;自动重新绑定与素材分页、关键词筛选、按素材 ID 导入不属于该行为。验证覆盖全库不可用而单目录正常、空目录、首次建绑、缺失与越权、查询失败及切号,并保留并发单次创建、创建响应丢失、冻结正文及生成账本恢复回归。 +- 单目录合同的定向验证使用 AGC Rust `external_canvas_context`、`canvas_generation_tests`、`project::resource_editor::tests`、`background_removal_tests` 与 `direct_tools_mcp::tests`,工具链 HTTP 夹具按单目录 GET 返回 `data.folder`;后端使用 `external_editor`、`external_mcp` 和 `protected_route_matrix_keeps_auth_methods_and_head_behavior`,覆盖 OpenAPI 自动增加单目录 MCP 工具后的目录数量及名称唯一性。`npm run check:editor-idempotency-procedures` 在独立 SpacetimeDB 中验证真实目录读取、归属、删除与只读行为,host 单测不能代替该运行时证据。 - 当前 binding wire schema 为 v2,payload fingerprint 必填。已落盘的 v1 project/resource JSON 只能经独立严格 wire 结构读取,在 lookup key、principal、本地 source、远端字段、对象键、时间和尺寸等不变量全部通过后重算指纹并原子回写 v2。不得用 `serde(default)` 把未知或篡改文档混入迁移。 - 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。 - 远端提交返回 `operationId` 后先持久化 accepted 身份,再在每次 poll、download 和本地 commit 前复验冻结会话;同步本地 commit 持有冻结会话租约,使切号只能发生在完整安装前或安装后。Tauri 手工生成同样使用稳定的 durable operation slot,不得使用进程内临时幂等键。历史资源编辑账本从 manifest 远端 resource ID 迁移到本地 asset ID 时,只允许在本地 asset、路径和内容摘要仍一致时兼容旧指纹,新请求不得恢复信任 manifest 远端 ID。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 766820acc..1a743e4d6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -617,6 +617,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAssetFolder` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布账号级素材文件夹表,归属于用户账号而不是 project;首次读取素材库时自动创建系统默认“项目素材”文件夹。文件夹支持重命名、折叠和删除,系统默认文件夹不能删除。 +- 单目录读取:`GET /api/editor/assets/folders/{folderId}` 与 `GET /api/external/v1/editor/assets/folders/{folderId}` 经同一 `spacetime-client` facade / 只读 procedure 按目录主键和 owner 校验,返回现有目录 DTO;不读取素材、不创建默认目录或修复媒体。External 要求 `editor:asset` scope;未认证 401、越权 403、缺失 404。既有全库、目录创建/修改/删除 API 合同和持久表结构保持不变;先部署后端再发布依赖新接口的 AGC 客户端,不提供全库回退。 - 索引:`by_editor_asset_folder_owner_user_id`。 ### `editor_asset` diff --git a/scripts/spacetime-editor-idempotency-smoke.mjs b/scripts/spacetime-editor-idempotency-smoke.mjs index 3b79c0836..0e6d9b94c 100644 --- a/scripts/spacetime-editor-idempotency-smoke.mjs +++ b/scripts/spacetime-editor-idempotency-smoke.mjs @@ -384,6 +384,28 @@ function assertDeletedConflict(result, recordLabel) { } async function runSmoke(configPath, serverUrl) { + const readFolder = (folderId, ownerUserId = 'receipt-owner') => + callProcedure(configPath, serverUrl, 'get_editor_asset_folder_and_return', { + folder_id: folderId, + owner_user_id: ownerUserId, + }); + const missingBeforeCreate = await readFolder('missing-folder', 'empty-owner'); + assert( + missingBeforeCreate.ok === false && + missingBeforeCreate.errorMessage === '素材文件夹不存在', + 'Missing folder read did not return the missing-folder error.', + ); + assert( + ( + await sqlRows( + configPath, + serverUrl, + 'SELECT folder_id FROM editor_asset_folder', + 'folders after missing read', + ) + ).length === 0, + 'Missing folder read created a default folder.', + ); const projectA = projectInput('project-a', 'First'); assertOk( await callProcedure( @@ -471,6 +493,73 @@ async function runSmoke(configPath, serverUrl) { 'asset folder drift replay', ); + const folderRowsBeforeRead = await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_asset_folder', + 'folders before read', + ); + const receiptsBeforeRead = await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_idempotent_create_receipt', + 'receipts before read', + ); + assert( + ( + await sqlRows( + configPath, + serverUrl, + 'SELECT asset_id FROM editor_asset', + 'assets before folder read', + ) + ).length === 0, + 'Folder read fixture must have no assets.', + ); + const emptyFolder = await readFolder('folder-a'); + assertOk(emptyFolder, 'empty asset folder read'); + assert( + emptyFolder.record[0] === 'folder-a' && + emptyFolder.record[1] === 'Folder A', + 'Folder read returned a different folder.', + ); + const foreignFolder = await readFolder('folder-a', 'other-owner'); + assert( + foreignFolder.ok === false && + foreignFolder.record === null && + foreignFolder.errorMessage === '无权访问该素材文件夹', + 'Foreign-owner folder read did not reject access.', + ); + const missingFolder = await readFolder('missing-folder'); + assert( + missingFolder.ok === false && + missingFolder.record === null && + missingFolder.errorMessage === '素材文件夹不存在', + 'Missing folder read did not fail closed.', + ); + assert( + JSON.stringify( + await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_asset_folder', + 'folders after read', + ), + ) === JSON.stringify(folderRowsBeforeRead), + 'Folder reads changed folder rows or timestamps.', + ); + assert( + JSON.stringify( + await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_idempotent_create_receipt', + 'receipts after read', + ), + ) === JSON.stringify(receiptsBeforeRead), + 'Folder reads changed durable receipts.', + ); + const sameRaceInput = projectInput('project-same-race', 'Same Race'); const sameRace = await Promise.all([ callProcedure( @@ -641,6 +730,12 @@ async function runSmoke(configPath, serverUrl) { }, ); assertOk(deleteFolderResult, 'asset folder delete'); + const deletedFolder = await readFolder('folder-a'); + assert( + deletedFolder.ok === false && + deletedFolder.errorMessage === '素材文件夹不存在', + 'Deleted folder read did not return the missing-folder error.', + ); assertDeletedConflict( await callProcedure( configPath, @@ -757,7 +852,7 @@ export async function main() { await runSmoke(configPath, serverUrl); console.log( - '[editor-idempotency-smoke] Passed exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.', + '[editor-idempotency-smoke] Passed folder reads (empty, missing, foreign owner, no writes), exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.', ); } catch (error) { const standaloneOutput = sanitizeDiagnostic( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d67344797..db2e78e56 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -48,7 +48,7 @@ use spacetime_client::editor_project::{ }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, @@ -2386,6 +2386,29 @@ pub async fn get_editor_asset_library( )) } +pub async fn get_editor_asset_folder( + State(state): State, + Path(folder_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let folder = state + .spacetime_client() + .get_editor_asset_folder(EditorAssetFolderGetRecordInput { + folder_id, + owner_user_id: current_owner_user_id(&authenticated), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorAssetFolderResponse { + folder: editor_asset_folder_payload_from_record(folder), + }, + )) +} + pub async fn create_editor_asset_folder( State(state): State, headers: HeaderMap, diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 07bd86ad6..123878eb0 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -17,11 +17,11 @@ use shared_contracts::external_generation::{ use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, - EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput, - ExternalGenerationJobRecord, SpacetimeClientError, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, + EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, + EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::editor_project_icon::{ @@ -468,6 +468,30 @@ pub async fn get_external_editor_asset_library( )) } +pub async fn get_external_editor_asset_folder( + State(state): State, + Path(folder_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let folder = state + .spacetime_client() + .get_editor_asset_folder(EditorAssetFolderGetRecordInput { + folder_id, + owner_user_id: principal.owner_user_id().to_string(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetFolderResponse { + folder: editor_asset_folder_payload_from_record(folder), + }, + )) +} + pub async fn create_external_editor_asset_folder( State(state): State, headers: HeaderMap, @@ -1194,7 +1218,11 @@ fn serialize_external_editor_image_sequence_frames( mod tests { use super::*; use crate::editor_project::PROJECT_COVER_SNAPSHOT_ASSET_KIND; - use axum::{Router, body::Body, routing::post}; + use axum::{ + Router, + body::Body, + routing::{get, post}, + }; use spacetime_client::{ EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectRecord, EditorProjectResourceRecord, @@ -2317,6 +2345,72 @@ mod tests { ); } + #[tokio::test] + async fn external_editor_asset_folder_read_requires_asset_scope_before_store_access() { + let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); + let app = Router::new() + .route( + "/api/external/v1/editor/assets/folders/{folder_id}", + get(get_external_editor_asset_folder), + ) + .layer(Extension(request_context(false))) + .layer(Extension(ExternalApiPrincipal::for_test("user-1", &[]))) + .with_state(state); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/api/external/v1/editor/assets/folders/folder-1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should complete"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn external_editor_asset_folder_read_maps_missing_and_foreign_owner_errors() { + for (message, status) in [ + ("素材文件夹不存在", StatusCode::NOT_FOUND), + ("无权访问该素材文件夹", StatusCode::FORBIDDEN), + ] { + assert_eq!( + map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())) + .status_code(), + status + ); + } + } + + #[test] + fn external_editor_asset_folder_read_openapi_matches_route_contract() { + let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); + let operation = + &parsed["paths"]["/api/external/v1/editor/assets/folders/{folderId}"]["get"]; + assert_eq!(operation["operationId"], "getEditorAssetFolder"); + assert_eq!( + operation["parameters"][0]["$ref"], + "#/components/parameters/FolderId" + ); + assert_eq!(operation["security"], json!([{ "ExternalApiKey": [] }])); + assert!( + operation["description"] + .as_str() + .unwrap() + .contains("editor:asset") + ); + assert_eq!( + operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/ExternalEditorAssetFolderResponse" + ); + for status in ["200", "400", "401", "403", "404"] { + assert!( + operation["responses"].get(status).is_some(), + "missing {status}" + ); + } + } + #[test] fn exported_openapi_json_contains_external_editor_routes_and_security() { let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs index 9d66978b6..02f47b42f 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -30,7 +30,7 @@ fn prepare( #[test] fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { assert_eq!(TOOLS.len(), 15); - assert_eq!(MCP_OPERATIONS.len(), 29); + assert_eq!(MCP_OPERATIONS.len(), 30); let legacy = MCP_OPERATIONS .iter() .map(mcp_operation_tool) @@ -52,7 +52,7 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { .is_some_and(|text| !text.is_empty()) ); } - assert_eq!(names.len(), 44); + assert_eq!(names.len(), 45); } #[test] diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 8ff87a7ee..176864efd 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -22,10 +22,10 @@ use crate::{ create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, delete_editor_project, edit_editor_image, extract_editor_ui_design_assets, generate_editor_image, generate_editor_scene, - get_editor_asset_library, get_editor_generation_pricing, get_editor_project, - list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, - remove_editor_image_background, rename_editor_project, save_editor_project_layout, - snap_editor_image_to_pixel_art, submit_editor_asset_showcase, + get_editor_asset_folder, get_editor_asset_library, get_editor_generation_pricing, + get_editor_project, list_editor_projects, list_public_editor_project_resources, + load_recent_editor_project, remove_editor_image_background, rename_editor_project, + save_editor_project_layout, snap_editor_image_to_pixel_art, submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, update_editor_project_resource_showcase, }, @@ -110,7 +110,9 @@ pub fn router(state: AppState) -> Router { ), ( "/api/editor/assets/folders/{folder_id}", - patch(update_editor_asset_folder).delete(delete_editor_asset_folder), + get(get_editor_asset_folder) + .patch(update_editor_asset_folder) + .delete(delete_editor_asset_folder), ), ("/api/editor/assets", post(create_editor_asset)), ( @@ -291,7 +293,7 @@ mod route_contract_tests { ("/api/editor/assets/folders", &["POST"]), ( "/api/editor/assets/folders/{folder_id}", - &["PATCH", "DELETE"], + &["GET", "PATCH", "DELETE"], ), ("/api/editor/assets", &["POST"]), ("/api/editor/assets/{asset_id}", &["PATCH", "DELETE"]), diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index dff6580ea..415a20777 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -21,10 +21,11 @@ use crate::{ generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, generate_external_editor_image, generate_external_editor_scene, generate_external_editor_sound_effect, generate_external_editor_video, - get_external_editor_asset_library, get_external_editor_generation_job, - get_external_editor_project, list_external_editor_projects, - load_recent_external_editor_project, openapi_json, remove_external_editor_image_background, - rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset, + get_external_editor_asset_folder, get_external_editor_asset_library, + get_external_editor_generation_job, get_external_editor_project, + list_external_editor_projects, load_recent_external_editor_project, openapi_json, + remove_external_editor_image_background, rename_external_editor_project, + save_external_editor_canvas, update_external_editor_asset, update_external_editor_asset_folder, }, external_mcp, @@ -93,7 +94,9 @@ pub fn router(state: AppState) -> Router { ), ( "/api/external/v1/editor/assets/folders/{folder_id}", - patch(update_external_editor_asset_folder).delete(delete_external_editor_asset_folder), + get(get_external_editor_asset_folder) + .patch(update_external_editor_asset_folder) + .delete(delete_external_editor_asset_folder), ), ( "/api/external/v1/editor/assets", @@ -252,7 +255,7 @@ mod route_contract_tests { ("/api/external/v1/editor/assets/folders", &["POST"]), ( "/api/external/v1/editor/assets/folders/{folder_id}", - &["PATCH", "DELETE"], + &["GET", "PATCH", "DELETE"], ), ("/api/external/v1/editor/assets", &["POST"]), ( diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 5e59eeedb..a12c42531 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -52,7 +52,7 @@ pub use self::editor_agent::{ pub use self::editor_project::{ AdminEditorAssetListRecordInput, AdminEditorAssetRecord, EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput, EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 671d20708..5e9f4c69c 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -362,6 +362,12 @@ pub struct EditorProjectResourcePublicShowcaseListRecordInput { pub cursor: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAssetFolderGetRecordInput { + pub folder_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorAssetFolderCreateRecordInput { pub folder_id: String, @@ -711,6 +717,15 @@ impl From } } +impl From for crate::module_bindings::EditorAssetFolderGetInput { + fn from(input: EditorAssetFolderGetRecordInput) -> Self { + Self { + folder_id: input.folder_id, + owner_user_id: input.owner_user_id, + } + } +} + impl From for crate::module_bindings::EditorAssetFolderCreateInput { diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index f34c083e7..166d75a6c 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -819,6 +819,28 @@ impl SpacetimeClient { .await } + pub async fn get_editor_asset_folder( + &self, + input: EditorAssetFolderGetRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "get_editor_asset_folder_and_return", + move |connection, sender| { + connection + .procedures() + .get_editor_asset_folder_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_asset_folder_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn create_editor_asset_folder( &self, input: EditorAssetFolderCreateRecordInput, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 0e2874cae..0946050b7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -208,6 +208,7 @@ pub mod editor_asset_create_input_type; pub mod editor_asset_delete_input_type; pub mod editor_asset_folder_create_input_type; pub mod editor_asset_folder_delete_input_type; +pub mod editor_asset_folder_get_input_type; pub mod editor_asset_folder_procedure_result_type; pub mod editor_asset_folder_snapshot_type; pub mod editor_asset_folder_table; @@ -460,6 +461,7 @@ pub mod get_asset_object_by_id_and_return_procedure; pub mod get_asset_object_by_location_and_return_procedure; pub mod get_asset_read_access_by_location_and_return_procedure; pub mod get_editor_agent_conversation_and_return_procedure; +pub mod get_editor_asset_folder_and_return_procedure; pub mod get_editor_asset_library_and_return_procedure; pub mod get_editor_generation_pricing_config_and_return_procedure; pub mod get_editor_project_and_return_procedure; @@ -1009,6 +1011,7 @@ pub use editor_asset_create_input_type::EditorAssetCreateInput; pub use editor_asset_delete_input_type::EditorAssetDeleteInput; pub use editor_asset_folder_create_input_type::EditorAssetFolderCreateInput; pub use editor_asset_folder_delete_input_type::EditorAssetFolderDeleteInput; +pub use editor_asset_folder_get_input_type::EditorAssetFolderGetInput; pub use editor_asset_folder_procedure_result_type::EditorAssetFolderProcedureResult; pub use editor_asset_folder_snapshot_type::EditorAssetFolderSnapshot; pub use editor_asset_folder_table::*; @@ -1261,6 +1264,7 @@ pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_ pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return; pub use get_editor_agent_conversation_and_return_procedure::get_editor_agent_conversation_and_return; +pub use get_editor_asset_folder_and_return_procedure::get_editor_asset_folder_and_return; pub use get_editor_asset_library_and_return_procedure::get_editor_asset_library_and_return; pub use get_editor_generation_pricing_config_and_return_procedure::get_editor_generation_pricing_config_and_return; pub use get_editor_project_and_return_procedure::get_editor_project_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs new file mode 100644 index 000000000..e9514dfb4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAssetFolderGetInput { + pub folder_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for EditorAssetFolderGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs new file mode 100644 index 000000000..bc951678a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_asset_folder_get_input_type::EditorAssetFolderGetInput; +use super::editor_asset_folder_procedure_result_type::EditorAssetFolderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetEditorAssetFolderAndReturnArgs { + pub input: EditorAssetFolderGetInput, +} + +impl __sdk::InModule for GetEditorAssetFolderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_editor_asset_folder_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_editor_asset_folder_and_return { + fn get_editor_asset_folder_and_return(&self, input: EditorAssetFolderGetInput) { + self.get_editor_asset_folder_and_return_then(input, |_, _| {}); + } + + fn get_editor_asset_folder_and_return_then( + &self, + input: EditorAssetFolderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_editor_asset_folder_and_return for super::RemoteProcedures { + fn get_editor_asset_folder_and_return_then( + &self, + input: EditorAssetFolderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( + "get_editor_asset_folder_and_return", + GetEditorAssetFolderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index c2de622d6..72710eb96 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -972,6 +972,12 @@ pub struct EditorAssetLibraryGetInput { pub now_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAssetFolderGetInput { + pub folder_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetFolderCreateInput { pub folder_id: String, @@ -1872,6 +1878,17 @@ pub fn find_editor_asset_group_source_and_return( } } +#[spacetimedb::procedure] +pub fn get_editor_asset_folder_and_return( + ctx: &mut ProcedureContext, + input: EditorAssetFolderGetInput, +) -> EditorAssetFolderProcedureResult { + match ctx.try_with_tx(|tx| get_editor_asset_folder(tx, input.clone())) { + Ok(folder) => editor_asset_folder_ok(Some(folder), None), + Err(message) => editor_asset_folder_error(message), + } +} + #[spacetimedb::procedure] pub fn create_editor_asset_folder_and_return( ctx: &mut ProcedureContext, @@ -5777,6 +5794,17 @@ fn create_editor_asset_folder( .ok_or_else(|| "素材文件夹创建失败".to_string()) } +fn get_editor_asset_folder( + ctx: &ReducerContext, + input: EditorAssetFolderGetInput, +) -> Result { + let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?; + let owner_user_id = + normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?; + require_owned_asset_folder(ctx, folder_id.as_str(), owner_user_id.as_str()) + .map(asset_folder_snapshot_from_row) +} + fn update_editor_asset_folder( ctx: &ReducerContext, input: EditorAssetFolderUpdateInput,