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 21f212f11..49df719b9 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 @@ -1006,16 +1006,58 @@ pub(crate) async fn external_editor_json_request( let response = crate::http_client::with_agc_main_site_marker(request) .send() .await - .map_err(|error| format!("{action}失败:{error}"))?; + .map_err(|error| { + format!( + "{action}失败:{}", + describe_external_request_failure(&error) + ) + })?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(format_external_http_error(action, status, &body)); } - response - .json::() - .await - .map_err(|error| format!("解析{action}响应失败:{error}")) + response.json::().await.map_err(|error| { + format!( + "解析{action}响应失败:{}", + describe_external_request_failure(&error) + ) + }) +} + +/// 把 reqwest 失败翻译成可现场定责的文案。 +/// +/// `reqwest::Error` 的 `Display` 只输出 kind:客户端预算内没读完正文(总超时)、 +/// 正文被提前截断和正文不是合法 JSON 都会显示成同一句 `error decoding response body`, +/// 现场无法区分是平台慢、链接慢还是响应被截断。这里补上 kind 语义与底层因链; +/// 因链只取错误文本,不拼接 URL,避免把绝对地址写进日志。 +fn describe_external_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时(连接、响应头或响应正文未在客户端预算内完成)" + } else if error.is_decode() { + "响应正文未完整返回或不是合法 JSON" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_connect() { + "连接失败" + } else if error.is_request() { + "请求发送失败" + } else { + "请求失败" + }; + let mut causes: Vec = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + return kind.to_string(); + } + format!("{kind}:{}", causes.join(" → ")) } /// Keep provider validation details useful to the operator without copying an @@ -1443,10 +1485,14 @@ pub(crate) async fn prepare_external_canvas_generation_context( let project_id = if let Some(project_id) = partial.remote_project_id.clone() { project_id } else { + // 这一步只需要按 projectId 确认绑定项目是否仍然存在,固定用摘要视图: + // 缺省 full 会把账号下每个项目的画布与全量资源都带回来,项目增长后会耗尽本次请求的 + // 客户端预算,现场表现为「解析读取外部画布项目响应失败:error decoding response body」。 + // 站内 `/api/editor/projects` 与 `/api/external/v1/editor/projects` 都支持该视图。 let projects_payload = external_editor_json_request( client .get(format!( - "{}{}", + "{}{}?view=summary", access.api_base_url(), access.api_route("/api/external/v1/editor/projects") )) @@ -8535,6 +8581,28 @@ mod canvas_generation_tests { ); } + /// reqwest 的 `Display` 只给 kind,超时 / 正文截断 / 非法 JSON 全都是同一句 + /// `error decoding response body`。这个用例钉住「至少把 kind 语义换成人话」, + /// 避免再退回无法定责的原始文案。 + #[tokio::test] + async fn external_request_failure_replaces_the_opaque_reqwest_kind() { + let error = reqwest::Client::new() + .get("http://127.0.0.1:1/healthz") + .timeout(Duration::from_secs(2)) + .send() + .await + .expect_err("closed port must fail"); + let described = describe_external_request_failure(&error); + // 该端口在真实环境里可能被直接拒绝、也可能被中间层吞掉直到超时,两种都必须能定责。 + assert!( + described.contains("连接失败") || described.contains("请求超时"), + "{described}" + ); + assert!(described.contains(":"), "必须补上底层因链:{described}"); + assert!(!described.contains("error sending request"), "{described}"); + assert!(!described.contains("http://127.0.0.1:1"), "{described}"); + } + fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream .set_nonblocking(false) @@ -8843,7 +8911,8 @@ mod canvas_generation_tests { }), ); false - } else if request.starts_with("GET /api/external/v1/editor/projects ") { + } else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") + { write_test_json_response( &mut stream, "200 OK", @@ -9213,7 +9282,7 @@ mod canvas_generation_tests { .expect("write concurrent generation png body"); return; } - if request.starts_with("GET /api/external/v1/editor/projects ") { + if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") { // 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。 write_test_json_response( stream, @@ -9687,7 +9756,7 @@ mod canvas_generation_tests { "Bearer token-b" => "b", unexpected => panic!("unexpected authorization {unexpected}"), }; - let response = if request.starts_with("GET /api/editor/projects ") { + let response = if request.starts_with("GET /api/editor/projects?view=summary ") { let projects = if project_created.get(account).copied().unwrap_or(false) { vec![serde_json::json!({ "projectId": format!("remote-project-{account}"), @@ -9853,7 +9922,7 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture concurrent binding request"); - let response = if request.starts_with("GET /api/editor/projects ") { + let response = if request.starts_with("GET /api/editor/projects?view=summary ") { let (state_lock, ready) = &*state; let mut state = state_lock.lock().expect("lock project fixture state"); if !state.project_created { @@ -9988,7 +10057,7 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture project partial request"); - if request.starts_with("GET /api/external/v1/editor/projects ") { + if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") { write_test_json_response( &mut stream, "200 OK", @@ -10122,7 +10191,7 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture frozen binding request"); - if request.starts_with("GET /api/external/v1/editor/projects ") { + if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") { write_test_json_response( &mut stream, "200 OK", @@ -10247,23 +10316,24 @@ 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 ") { - 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/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}"); + }; write_test_json_response(&mut stream, "200 OK", &response); } }); 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 e085d43d1..923e1c5d3 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 @@ -862,11 +862,22 @@ fn resource_edit_state_sha256(value: &T, label: &str) -> Result Result<(), String> { - match phase { - ResourceEditLedgerPhase::RemoteFailed => { - Err("remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求".to_string()) - } +/// 只有仍可继续的阶段允许再次请求远端;终态必须在这里失败关闭。 +/// +/// 终态文案要带出稳定失败码和唯一出口(移出恢复队列):否则用户只会看到一句 +/// 「已明确失败」并反复点重试。上游原文不进账本(见 `terminal_failure_code` 的写入边界), +/// 这里只透传分类码,不做二次解释。 +fn ensure_resource_edit_phase_resumable(ledger: &ResourceEditLedger) -> Result<(), String> { + match ledger.phase { + ResourceEditLedgerPhase::RemoteFailed => Err(format!( + "remote-terminal-failed: 远端资源编辑已明确失败({}),不允许再次请求;如需重试,请先在待恢复资源编辑中把它移出恢复队列", + ledger + .terminal_failure_code + .as_deref() + .map(str::trim) + .filter(|code| !code.is_empty()) + .unwrap_or("remote-failed") + )), ResourceEditLedgerPhase::Archived => { Err("resource-edit-archived: 资源编辑已移出恢复队列".to_string()) } @@ -5122,7 +5133,7 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at( { return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string()); } - ensure_resource_edit_phase_resumable(&ledger.phase)?; + ensure_resource_edit_phase_resumable(&ledger)?; let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None)?; match prepare_resource_edit_service_identity( @@ -5195,7 +5206,7 @@ fn confirm_resource_edit_service_identity_under_lease( return Err("资源编辑服务身份确认对应的 operation 身份已变化".to_string()); } validate_resource_edit_service_identity_owner(&ledger, platform_session)?; - ensure_resource_edit_phase_resumable(&ledger.phase)?; + ensure_resource_edit_phase_resumable(&ledger)?; let confirmation = ledger .service_identity_confirmation .clone() @@ -5321,7 +5332,7 @@ pub(crate) async fn resume_local_project_resource_edit_at( { return Err("待恢复的资源编辑账本身份无效".to_string()); } - ensure_resource_edit_phase_resumable(&ledger.phase)?; + ensure_resource_edit_phase_resumable(&ledger)?; let source_asset = ledger .source_asset_id .as_deref() @@ -5552,7 +5563,7 @@ pub(crate) async fn derive_local_project_resource_at( { return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string()); } - ensure_resource_edit_phase_resumable(&ledger.phase)?; + ensure_resource_edit_phase_resumable(&ledger)?; ledger } None => { @@ -6157,8 +6168,8 @@ mod tests { let request = read_http_request(&mut stream); let request_line = request.lines().next().unwrap_or_default(); requests.push(request.clone()); - if request_line.starts_with("GET /api/external/v1/editor/projects ") - || request_line.starts_with("GET /api/editor/projects ") + if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ") + || request_line.starts_with("GET /api/editor/projects?view=summary ") { write_json( &mut stream, @@ -6686,7 +6697,9 @@ mod tests { let request = read_http_request(&mut stream); sender.send(request.clone()).expect("capture B request"); let request_line = request.lines().next().unwrap_or_default(); - if request_line.starts_with("GET /api/external/v1/editor/projects ") { + if request_line + .starts_with("GET /api/external/v1/editor/projects?view=summary ") + { write_json( &mut stream, "200 OK", @@ -7186,7 +7199,7 @@ mod tests { ); assert!(requests.iter().all(|request| { let line = request.lines().next().unwrap_or_default(); - line.starts_with("GET /api/external/v1/editor/projects ") + line.starts_with("GET /api/external/v1/editor/projects?view=summary ") || line.starts_with("GET /api/external/v1/editor/assets/library ") })); } @@ -9293,7 +9306,7 @@ mod tests { .expect("capture External video request"); let request_line = request.lines().next().unwrap_or_default(); let request_lower = request.to_ascii_lowercase(); - if request_line.starts_with("GET /api/external/v1/editor/projects ") { + if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ") { write_json( &mut stream, "200 OK", 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 7fb65e509..3428ad8cb 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 @@ -391,8 +391,8 @@ fn assert_developer_submission(request: &str) { fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool { let line = request.lines().next().unwrap_or_default(); - if line.starts_with("GET /api/external/v1/editor/projects ") - || line.starts_with("GET /api/editor/projects ") + if line.starts_with("GET /api/external/v1/editor/projects?view=summary ") + || line.starts_with("GET /api/editor/projects?view=summary ") { write_json( stream, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 48566b87d..621e24906 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -3337,8 +3337,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } let normalized_request = request.to_ascii_lowercase(); let (status, content_type, body) = if request - .starts_with("GET /api/external/v1/editor/projects ") - || request.starts_with("GET /api/editor/projects ") + .starts_with("GET /api/external/v1/editor/projects?view=summary ") + || request.starts_with("GET /api/editor/projects?view=summary ") { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", projects_body.as_bytes().to_vec()) @@ -3564,8 +3564,8 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { let (status, body) = match index { 0 => { assert!( - request.starts_with("GET /api/external/v1/editor/projects ") - || request.starts_with("GET /api/editor/projects ") + request.starts_with("GET /api/external/v1/editor/projects?view=summary ") + || request.starts_with("GET /api/editor/projects?view=summary ") ); ( "200 OK", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bb21713d5..e708d7af2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -71,6 +71,15 @@ - 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTasksSidebar.css}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/{resourceCanvasAssetGenerationTasksPanel.test.tsx,resourceCanvasAssetGenerationTasksSidebarStyle.test.ts,resourceCanvasGenerationTasksSidebarDismiss.test.tsx}`、PRD §3.10。未动 Rust、SpacetimeDB、`packages/`、共享弹窗组件。 - 已知未覆盖:真实客户端观感(右上角坐标相对画布顶边的落点、与运行表现层版本入口的间距)未在 Tauri 里目视确认;窄屏(≤480px)只有声明级断言。 +## 2026-09-21 画布绑定前置查询收口为项目摘要,失败文案补因链 + +- 背景:dev 上「AI 生成图片」连续失败,卡片显示 `解析读取外部画布项目响应失败:error decoding response body`,每条恰好 `1 分 00 秒`;同批的远端资源编辑终态只提示「已明确失败」,用户看不到原因也看不到下一步。(同批「图标素材切片超过 64 个」已由本文件「图集切片上限:客户端结果门从 64 对齐到平台契约的 256」条目决策,这里不再重复。) +- 决策(项目列表视图):`GET /api/editor/projects` 与 `GET /api/external/v1/editor/projects` 共用同一套 `view` 取值与摘要投影(缺省 `full` 保持兼容,未知取值失败关闭);`summary` 只回传 `projectId / title / updatedAt / cover`,既不做内联媒体修复,也不带画布与全量资源。投影实现收敛到 `editor_project.rs` 一份,外部 API 与 MCP 复用同一份;AGC 画布绑定前置查询固定使用 `?view=summary`,它只需要 `projectId`。 +- 决策(错误文案与终态出口):外部请求失败文案补 kind 语义与底层因链(`reqwest::Error` 的 `Display` 只有 kind,超时 / 正文截断 / 非法 JSON 显示成同一句话),且不拼接 URL;远端资源编辑终态文案带出稳定失败码并指向唯一出口「移出恢复队列」,上游原文继续不写入账本。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/external_editor_api.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs`、`project/resource_editor.rs` 与对应夹具。 +- 验证方式:`cargo test --locked -p api-server -- summary`(含站内 `view=summary` 跳过媒体修复、未知 view 返回 400 的新用例)、AGC 壳 `agent::generation::`(99 项)与 `project::resource_editor::`(59 项)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 + + ## Unity 与 Godot 常用操作指导 两种编辑器的操作指导复用客户端审核 Skill pack:DirectProject 通过原生 Skill 或既有审核资源读取入口按需取得,Agent Runtime 的对应执行工具说明嵌入同源参考。指南不改变插件可用性、执行授权或 Runner 回执;只读说明不能证明编辑器已连接。常用示例与执行失败/部分修改、保存、撤销边界在同一参考中维护,避免提示词和文档各存一份代码。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cf6db49d9..a8f7b69d4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -42,6 +42,22 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 `Genarrative-Agc-Windows-Build` 在阶段里用 `AGC_WINDOWS_PATH` 整体替换 PATH、不继承节点机器的 PATH,所以 Godot C++ 引导需要的 CMake 与 Python 必须显式写进这份白名单,装在机器 PATH 上并不生效。2026-09-21 的 #97–#99 连续失败都停在 `Get-Command cmake.exe`(#93–#96 是更早的手写 C ABI 在 MSVC C 模式下的对齐问题):节点只有 Visual Studio Build Tools(`C:\BuildTools`)自带的 CMake 3.31,缺 Python 3。修复后白名单包含 `C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin`、`C:\Python312`、`C:\Python312\Scripts`,preflight 校验 CMake ≥3.25、Python 3 和 Visual Studio 17 2022 生成器;把 `cmake.exe` 单独复制到别的目录会丢掉 `share/cmake-*/Modules`,不能替代加入安装目录。新节点的 Python 用 `python-3.12.10-amd64.exe /quiet InstallAllUsers=1 TargetDir=C:\Python312 PrependPath=1 Include_launcher=1 InstallLauncherAllUsers=1` 静默安装即可,CMake 不必另装。 +## AGC 画布绑定前置查询不能取全量项目列表 + +- 现象:dev 上「AI 生成图片」连续失败,卡片显示 `解析读取外部画布项目响应失败:error decoding response body`,每条恰好 `1 分 00 秒`(三条同因,各自独立计时)。 +- 原因:绑定前置的 `GET /api/(external/v1/)editor/projects` 缺省 `view=full`,会把账号下每个项目的画布与全量资源一起返回(19 个大项目的 fixture 就已超过 4 MiB);客户端这条请求只有 60 秒预算,卡在读正文时被 reqwest 总超时打断。而 `reqwest::Error` 的 `Display` 只打印 kind,超时、正文被截断和非法 JSON 显示成同一句话,现场看不出根因。 +- 处理:只确认项目身份的消费者固定取 `view=summary`(站内与外部路由都支持,缺省 `full` 不变,未知取值失败关闭);摘要视图不做内联媒体修复、不带画布与全量资源;外部请求失败文案补 kind 语义与 source 因链,且不拼接 URL。 +- 验证:站内路由用例断言 `view=summary` 不回传 `canvas / layers / resources` 且不触发媒体修复、`view=unknown` 返回 400;AGC 壳用例断言失败文案不再等于 `error decoding response body`、补出因链且不含绝对地址。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/external_editor_api.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs`。 + +## 远端资源编辑终态必须指出唯一出口 + +- 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 `remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求`,既没有原因也没有下一步。 +- 原因:上一次同 `operationId` 的请求被平台确定性拒绝(HTTP 400 或任务 `failed`)后,账本落到 `remote-failed`,之后所有重试都在 `ensure_resource_edit_phase_resumable` 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。 +- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担。 +- 验证:`remote_failed_status_is_terminal_and_can_only_be_archived`、`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过,账本序列化不含上游失败原文。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 + ## Tauri `--no-sign` 会连带跳过 updater 签名 AGC macOS 发布入口一度传入 `--no-sign`(目的是绕过没有 Apple 证书的代码签名),结果 Tauri 打印 `Warn Updater signing is skipped due to --no-sign flag.`,产物只有 `*​.app.tar.gz` 而没有 `.sig`,发布入口按设计在「缺少更新包签名」处失败关闭(2026-09-20 首次 Jenkins 实跑命中)。正确做法是不传 `--no-sign`,改为剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名——minisign 更新包签名与 Apple 代码签名这两个开关在 Tauri 里并不独立。Apple 签名状态要按 `codesign -dv` 实测记录,不能硬编码。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 100d89b41..cd4e3542e 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -171,6 +171,8 @@ const EDITOR_LEGACY_IMAGE_ASSET_KIND: &str = "image"; pub(crate) const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; +/// 项目封面快照资源 kind:项目摘要的封面只取这类资源,不用普通生成图兜底。 +pub(crate) const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot"; const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; @@ -724,6 +726,93 @@ pub struct EditorProjectListResponse { projects: Vec, } +/// 项目列表视图:`full` 保留历史语义(内联媒体修复 + 画布与全量资源), +/// `summary` 只回传项目身份、标题、更新时间和封面摘要。 +/// +/// 站内路由与 `/api/external/v1` 共用同一套视图取值;缺省必须保持 `full`, +/// 未知取值失败关闭,不能静默回落。 +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum EditorProjectListView { + #[default] + Full, + Summary, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorProjectListQuery { + #[serde(default)] + pub(crate) view: EditorProjectListView, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorProjectSummary { + pub(crate) project_id: String, + pub(crate) title: String, + pub(crate) updated_at: String, + pub(crate) cover: Option, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorProjectCoverSummary { + pub(crate) resource_id: String, + pub(crate) object_key: String, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) updated_at: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorProjectSummaryListResponse { + pub(crate) projects: Vec, +} + +/// 项目摘要只依赖记录本身:封面取最新且存在非空 `objectKey` 的 `project-cover-snapshot` +/// 资源,不回传画布、图层与全量资源,也不做内联媒体修复。 +/// +/// 需要它的调用方是「只确认项目身份」的消费者(画布绑定前置查询、MCP 项目列表等): +/// 账号项目增长后完整列表会带上每个项目的画布与全量资源,既慢又可能超出调用方预算。 +pub(crate) fn editor_project_summary_from_record( + record: EditorProjectRecord, +) -> EditorProjectSummary { + let cover = record + .resources + .iter() + .filter_map(|resource| { + (resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND)) + .then_some(resource) + .zip( + resource + .object_key + .as_deref() + .filter(|object_key| !object_key.trim().is_empty()), + ) + }) + .max_by(|(left, _), (right, _)| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.resource_id.cmp(&right.resource_id)) + }) + .map(|(resource, object_key)| EditorProjectCoverSummary { + resource_id: resource.resource_id.clone(), + object_key: object_key.to_string(), + width: resource.width, + height: resource.height, + updated_at: resource.updated_at.clone(), + }); + + EditorProjectSummary { + project_id: record.project_id, + title: record.title, + updated_at: record.updated_at, + cover, + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectDeleteResponse { @@ -1932,6 +2021,7 @@ pub async fn get_editor_generation_pricing( pub async fn list_editor_projects( State(state): State, + Query(query): Query, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { @@ -1941,6 +2031,18 @@ pub async fn list_editor_projects( .list_editor_projects(owner_user_id) .await .map_err(map_editor_project_error)?; + // 摘要视图按记录直接投影:既不修复内联媒体,也不把画布与全量资源写进响应。 + if query.view == EditorProjectListView::Summary { + return Ok(json_success_body( + Some(&request_context), + EditorProjectSummaryListResponse { + projects: project_records + .into_iter() + .map(editor_project_summary_from_record) + .collect(), + }, + )); + } let mut projects = Vec::with_capacity(project_records.len()); for project in project_records { projects.push(editor_project_payload_from_record( diff --git a/server-rs/crates/api-server/src/editor_project/metadata_tests.rs b/server-rs/crates/api-server/src/editor_project/metadata_tests.rs index cf3b2b04f..cabe3ae0f 100644 --- a/server-rs/crates/api-server/src/editor_project/metadata_tests.rs +++ b/server-rs/crates/api-server/src/editor_project/metadata_tests.rs @@ -258,6 +258,53 @@ async fn narrow_project_reads_preserve_owner_and_use_media_repair_results() { ); } +#[tokio::test] +async fn project_list_summary_view_skips_media_repair_and_full_payload() { + let projects = Arc::new(RecordingProjects::default()); + let media = Arc::new(RecordingMediaRepair::default()); + let router = metadata_router(projects.clone(), media.clone()); + + let (status, body) = send(&router, "GET", "/projects?view=summary", Value::Null).await; + assert_eq!(status, StatusCode::OK); + let summary = body.pointer("/projects/0").expect("摘要列表应带项目元素"); + assert_eq!(summary["projectId"], "project-fixture"); + assert_eq!(summary["title"], "原名称"); + // 摘要按记录直接投影,因此保留记录原时间,也不会带上画布 / 图层 / 全量资源。 + assert_eq!(summary["updatedAt"], "0.000000Z"); + for field in ["canvas", "layers", "resources"] { + assert!(summary.get(field).is_none(), "摘要视图不得回传 {field}"); + } + assert!( + media.calls.lock().unwrap().is_empty(), + "摘要视图不得触发内联媒体修复" + ); + + let (status, full) = send(&router, "GET", "/projects", Value::Null).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + full.pointer("/projects/0/updatedAt").unwrap(), + "media-repaired" + ); + assert_eq!(media.calls.lock().unwrap().len(), 1); + + let unknown_view = router + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects?view=compact") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + unknown_view.status(), + StatusCode::BAD_REQUEST, + "未知 view 不得静默回落到 full" + ); +} + #[tokio::test] async fn narrow_project_writes_preserve_owner_idempotency_and_revision() { let projects = Arc::new(RecordingProjects::default()); 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 6a53d32d8..2a5eb877f 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -17,7 +17,7 @@ use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, + EditorProjectGetRecordInput, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; @@ -36,13 +36,14 @@ use crate::{ EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload, EditorAssetLibraryPayload, EditorAssetPayload, EditorBackgroundRemovalRequest, EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest, - EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload, + EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView, + EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse, EditorUiDesignAssetExtractionRequest, current_utc_micros, editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, editor_asset_payload_from_record, editor_idempotent_create_id, editor_project_payload_from_record, editor_project_resource_payload_from_record, - enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner, - enqueue_editor_image_generation_for_owner, + editor_project_summary_from_record, enqueue_editor_background_removal_for_owner, + enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, ensure_generic_editor_image_generation_contract, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, @@ -71,22 +72,6 @@ const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500; const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; -const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot"; - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -enum ExternalEditorProjectListView { - #[default] - Full, - Summary, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExternalEditorProjectListQuery { - #[serde(default)] - view: ExternalEditorProjectListView, -} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -225,31 +210,6 @@ pub struct ExternalEditorProjectListResponse { projects: Vec, } -#[derive(Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExternalEditorProjectSummary { - project_id: String, - title: String, - updated_at: String, - cover: Option, -} - -#[derive(Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExternalEditorProjectCoverSummary { - resource_id: String, - object_key: String, - width: u32, - height: u32, - updated_at: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExternalEditorProjectSummaryListResponse { - projects: Vec, -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectDeleteResponse { @@ -334,7 +294,7 @@ pub async fn create_external_editor_project( pub async fn list_external_editor_projects( State(state): State, - Query(query): Query, + Query(query): Query, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, AppError> { @@ -346,7 +306,7 @@ pub async fn list_external_editor_projects( .map_err(map_editor_project_error)?; match query.view { - ExternalEditorProjectListView::Full => Ok(json_success_body( + EditorProjectListView::Full => Ok(json_success_body( Some(&request_context), ExternalEditorProjectListResponse { projects: projects @@ -355,55 +315,18 @@ pub async fn list_external_editor_projects( .collect(), }, )), - ExternalEditorProjectListView::Summary => Ok(json_success_body( + EditorProjectListView::Summary => Ok(json_success_body( Some(&request_context), - ExternalEditorProjectSummaryListResponse { + EditorProjectSummaryListResponse { projects: projects .into_iter() - .map(external_editor_project_summary_from_record) + .map(editor_project_summary_from_record) .collect(), }, )), } } -fn external_editor_project_summary_from_record( - record: EditorProjectRecord, -) -> ExternalEditorProjectSummary { - let cover = record - .resources - .iter() - .filter_map(|resource| { - (resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND)) - .then_some(resource) - .zip( - resource - .object_key - .as_deref() - .filter(|object_key| !object_key.trim().is_empty()), - ) - }) - .max_by(|(left, _), (right, _)| { - left.updated_at - .cmp(&right.updated_at) - .then_with(|| left.resource_id.cmp(&right.resource_id)) - }) - .map(|(resource, object_key)| ExternalEditorProjectCoverSummary { - resource_id: resource.resource_id.clone(), - object_key: object_key.to_string(), - width: resource.width, - height: resource.height, - updated_at: resource.updated_at.clone(), - }); - - ExternalEditorProjectSummary { - project_id: record.project_id, - title: record.title, - updated_at: record.updated_at, - cover, - } -} - pub async fn load_recent_external_editor_project( State(state): State, Extension(request_context): Extension, @@ -1244,9 +1167,11 @@ fn serialize_external_editor_image_sequence_frames( #[cfg(test)] mod tests { use super::*; + use crate::editor_project::PROJECT_COVER_SNAPSHOT_ASSET_KIND; use axum::{Router, body::Body, routing::post}; use spacetime_client::{ - EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectRecord, + EditorProjectResourceRecord, }; use tower::ServiceExt; @@ -1400,29 +1325,29 @@ mod tests { let without_view = "http://localhost/api/external/v1/editor/projects" .parse() .expect("测试 URI 应合法"); - let Query(query) = Query::::try_from_uri(&without_view) + let Query(query) = Query::::try_from_uri(&without_view) .expect("缺省 view 应保持 full 兼容语义"); - assert_eq!(query.view, ExternalEditorProjectListView::Full); + assert_eq!(query.view, EditorProjectListView::Full); let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full" .parse() .expect("测试 URI 应合法"); - let Query(query) = Query::::try_from_uri(&explicit_full) + let Query(query) = Query::::try_from_uri(&explicit_full) .expect("显式 full 应合法"); - assert_eq!(query.view, ExternalEditorProjectListView::Full); + assert_eq!(query.view, EditorProjectListView::Full); let summary = "http://localhost/api/external/v1/editor/projects?view=summary" .parse() .expect("测试 URI 应合法"); - let Query(query) = Query::::try_from_uri(&summary) - .expect("summary 应合法"); - assert_eq!(query.view, ExternalEditorProjectListView::Summary); + let Query(query) = + Query::::try_from_uri(&summary).expect("summary 应合法"); + assert_eq!(query.view, EditorProjectListView::Summary); let unknown = "http://localhost/api/external/v1/editor/projects?view=compact" .parse() .expect("测试 URI 应合法"); assert!( - Query::::try_from_uri(&unknown).is_err(), + Query::::try_from_uri(&unknown).is_err(), "未知 view 不得静默回落到 full" ); } @@ -1456,7 +1381,7 @@ mod tests { ), ]); - let summary = external_editor_project_summary_from_record(project); + let summary = editor_project_summary_from_record(project); let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化"); assert_eq!( @@ -1496,7 +1421,7 @@ mod tests { ), ]); - let summary = external_editor_project_summary_from_record(project); + let summary = editor_project_summary_from_record(project); assert_eq!(summary.cover, None); assert_eq!( serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"], @@ -1529,7 +1454,7 @@ mod tests { .expect("完整项目列表应可序列化"); let summaries = projects .into_iter() - .map(external_editor_project_summary_from_record) + .map(editor_project_summary_from_record) .collect::>(); let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化");