修复画布绑定查询与生成失败归因
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

- 画布绑定前置的项目列表查询改为 view=summary,站内与外部路由统一支持摘要视图(缺省 full 兼容,未知取值 400)
- 项目摘要投影收敛到 editor_project,外部 API 与 MCP 复用同一份实现,摘要不做内联媒体修复也不回传画布与全量资源
- 外部编辑器请求失败文案补 kind 语义与底层因链,区分请求超时、正文截断与非法 JSON,不拼接 URL
- 远端资源编辑终态文案带出稳定失败码并指向移出恢复队列这一唯一出口,上游原文仍不写入账本
- 补齐站内摘要视图与画布测试夹具的请求断言用例
- 更新共享决策记录与踩坑记录(图集切片上限条目归上游 #442,本提交不重复)
This commit is contained in:
kdletters
2026-09-21 13:21:39 +08:00
parent 2e31a485d8
commit 1cb91f9444
9 changed files with 330 additions and 148 deletions
@@ -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::<serde_json::Value>()
.await
.map_err(|error| format!("解析{action}响应失败:{error}"))
response.json::<serde_json::Value>().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<String> = 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);
}
});
@@ -862,11 +862,22 @@ fn resource_edit_state_sha256<T: Serialize>(value: &T, label: &str) -> Result<St
.map_err(|error| format!("序列化{label}身份失败:{error}"))
}
fn ensure_resource_edit_phase_resumable(phase: &ResourceEditLedgerPhase) -> 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",
@@ -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,
@@ -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",
@@ -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 packDirectProject 通过原生 Skill 或既有审核资源读取入口按需取得,Agent Runtime 的对应执行工具说明嵌入同源参考。指南不改变插件可用性、执行授权或 Runner 回执;只读说明不能证明编辑器已连接。常用示例与执行失败/部分修改、保存、撤销边界在同一参考中维护,避免提示词和文档各存一份代码。
@@ -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` 实测记录,不能硬编码。
@@ -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<EditorProjectPayload>,
}
/// 项目列表视图:`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<EditorProjectCoverSummary>,
}
#[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<EditorProjectSummary>,
}
/// 项目摘要只依赖记录本身:封面取最新且存在非空 `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<EditorProjectState>,
Query(query): Query<EditorProjectListQuery>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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(
@@ -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());
@@ -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<EditorProjectPayload>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectSummary {
project_id: String,
title: String,
updated_at: String,
cover: Option<ExternalEditorProjectCoverSummary>,
}
#[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<ExternalEditorProjectSummary>,
}
#[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<AppState>,
Query(query): Query<ExternalEditorProjectListQuery>,
Query(query): Query<EditorProjectListQuery>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -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::<ExternalEditorProjectListQuery>::try_from_uri(&without_view)
let Query(query) = Query::<EditorProjectListQuery>::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::<ExternalEditorProjectListQuery>::try_from_uri(&explicit_full)
let Query(query) = Query::<EditorProjectListQuery>::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::<ExternalEditorProjectListQuery>::try_from_uri(&summary)
.expect("summary 应合法");
assert_eq!(query.view, ExternalEditorProjectListView::Summary);
let Query(query) =
Query::<EditorProjectListQuery>::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::<ExternalEditorProjectListQuery>::try_from_uri(&unknown).is_err(),
Query::<EditorProjectListQuery>::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::<Vec<_>>();
let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化");