From 93ef6cebf33f53600cbc0a566c18b98b10766b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 19:55:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=94=9F=203D=20=E7=9A=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E9=A2=84=E6=A3=80=E6=94=B9=E4=B8=BA=E6=8C=89=E5=BD=92?= =?UTF-8?q?=E7=B1=BB=E4=BF=A1=E5=8F=B7=E6=94=B6=E6=95=9B=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8B=BF=E7=8A=B6=E6=80=81=E7=A0=81=E7=8C=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server:`resolve_editor_reference_record_by_id_for_owner` 拆出 `..._classified` 版本,失败带「引用不可用 / 其它」归类;原函数保持逐字段一致的对外行为(其它调用方不受影响) - api-server tripo3d/image_source.rs:注入的解析闭包改为返回归类后的失败,只对 `Unavailable` 收敛成「图片输入必须是当前账号已登记的画布资源或素材」,400 / 404 里的 runtime 与通用过程错误原样上报;模块注释同步 - 用例:`image_source_resolution_only_collapses_unavailable_references` 改成「Unavailable 收敛、400/404/403/409/5xx 一律保留原错」 - docs/project-memory:pitfalls 新增「4xx 不等于用户输入错」一条,记下这条口径与新增模块文案时要同步的清单 --- docs/project-memory/shared-memory/pitfalls.md | 9 +++ .../crates/api-server/src/editor_project.rs | 29 +++++-- .../api-server/src/tripo3d/image_source.rs | 79 +++++++++++-------- 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b2152a47d..a64be53e0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 4xx 不等于「用户输入错」:服务端没配好不能说成参数写错 + +- **现象**:Tripo 返回 401 / 403 时客户端看到「请检查参数后重试」;落点预检把「当前 identity 无权调用模型生成运行时服务」(403)与「模型定价服务身份尚未初始化」(400)都说成「生成结果的落点必须是当前账号已存在的画布项目或素材库文件夹」;图生 3D 把这两档也收敛成「图片输入必须是当前账号已登记的画布资源或素材」。 +- **成因**:两处都在错误的粒度上判断——provider 错误只看状态码区间(4xx → 400),定点预检只看 `is_client_error()` / 只看 400 与 404。这个区间里混着「密钥没配好」「找不到」「冲突」和「真的是参数写错」,按区间归类必然把服务端配置故障说成用户操作错误。 +- **处理(现行口径)**:`tripo3d/errors.rs` 给 401/403、404、409 与其余 4xx 各写一条分支(只有 400 / 422 是 `tripo-api-rejected`);定点查询(引用 / 落点)先经 `classify_editor_point_lookup_failure` 归类,只有「目标不存在 / 不属于当前账号」的固定文案才是 `Unavailable`,调用方也只对 `Unavailable` 改写成自己的文案。 +- **易错点**:不要再写「4xx 一律转 400」,也不要按「是不是 4xx」判断引用 / 落点不可用;模块新增或改写这类文案时要同步 `EDITOR_POINT_LOOKUP_UNAVAILABLE_MARKERS`,否则要么把新故障误报成用户问题,要么把用户侧的不可用变成 5xx。 +- **验证**:`cargo test --locked -p api-server -- tripo3d::errors tripo3d::target tripo3d::image_source editor_point_lookup`。 +- **关联**:`server-rs/crates/api-server/src/editor_project.rs`、`tripo3d/{errors.rs,target.rs,image_source.rs}`。 + ## 隐藏滚动条的横向滚动容器不等于「放得下」 - **现象**:桌面端画布底部工具栏尾部几个工具(含「生成 3D 模型」入口)看不到,用户反馈成「工具栏变成横向可滚动的了」「是不是限宽了」。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 3cfc17522..7d9755689 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4336,21 +4336,36 @@ pub(crate) async fn resolve_editor_reference_record_by_id_for_owner( owner_user_id: &str, reference_id: &str, ) -> Result { + resolve_editor_reference_record_by_id_for_owner_classified(state, owner_user_id, reference_id) + .await + .map_err(EditorPointLookupFailure::into_error) +} + +/// 与 [`resolve_editor_reference_record_by_id_for_owner`] 同一份查询,但把「引用不可用」与 +/// 其它失败分开给出:调用方据此收敛,不再靠 400 / 404 猜(这两档里混着 runtime 故障与 +/// 通用过程错误,按状态码猜会把服务端问题说成用户的引用写错了)。 +pub(crate) async fn resolve_editor_reference_record_by_id_for_owner_classified( + state: &AppState, + owner_user_id: &str, + reference_id: &str, +) -> Result { let reference_id = reference_id.trim(); if reference_id.is_empty() { - return Err( + return Err(EditorPointLookupFailure::other( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "referenceId", "message": "编辑器引用 ID 不能为空。", })), - ); + )); } let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) + EditorPointLookupFailure::other( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })), + ) })?; state .spacetime_client() @@ -4360,7 +4375,7 @@ pub(crate) async fn resolve_editor_reference_record_by_id_for_owner( reference_id: reference_id.to_string(), }) .await - .map_err(map_editor_project_error) + .map_err(classify_editor_point_lookup_failure) } fn ensure_editor_image_edit_source_reference_id_shape( diff --git a/server-rs/crates/api-server/src/tripo3d/image_source.rs b/server-rs/crates/api-server/src/tripo3d/image_source.rs index bfda94501..c4a29ec01 100644 --- a/server-rs/crates/api-server/src/tripo3d/image_source.rs +++ b/server-rs/crates/api-server/src/tripo3d/image_source.rs @@ -7,9 +7,10 @@ //! `file_token`。**不把带签名的临时地址交给第三方**,也不接受 URL / data URL。 //! //! 预检只按主键定点查引用,不拉取当前用户的工程列表或素材库;跨 owner、已删除与不存在 -//! 收敛成同一句 400,避免把别的账号是否存在该 ID 变成可探测信息。只有这类「引用不可用」 -//! 才是 400:403(服务身份校验失败)、409(版本冲突)与 5xx 都是基础设施 / 并发问题, -//! 按原状态码上报;「记录已解析但缺对象键」是行本身不完整,按 502 上报。 +//! 收敛成同一句 400,避免把别的账号是否存在该 ID 变成可探测信息。判据是查询给出的 +//! [`EditorPointLookupFailure`] 归类信号——不是状态码:403(服务身份校验失败)、409(版本 +//! 冲突)与 5xx 都是基础设施 / 并发问题,原样上报;「记录已解析但缺对象键」是行本身不完整, +//! 按 502 上报。 //! //! 定点查询是注入到 `resolve_source_object_key_with` 的参数,解析每分支只发一次;测试按可观察 //! 行为断言(查询次数、查询 ID、带出的对象键、各分支状态码),不依赖源码文本。 @@ -26,8 +27,9 @@ use spacetime_client::EditorReferenceRecord; use crate::{ editor_project::{ + EditorPointLookupFailure, EditorPointLookupKind, read_editor_reference_image_object_with_client, - resolve_editor_reference_record_by_id_for_owner, + resolve_editor_reference_record_by_id_for_owner_classified, }, http_error::AppError, state::AppState, @@ -78,9 +80,13 @@ async fn resolve_source_object_key( ) -> Result { // 生产路径只走「按 ID 定点查一条引用」:不列工程、不列素材库,也不读图片字节。 resolve_source_object_key_with(source, |reference_id| async move { - resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, &reference_id) - .await - .map(ResolvedReference::from) + resolve_editor_reference_record_by_id_for_owner_classified( + state, + owner_user_id, + &reference_id, + ) + .await + .map(ResolvedReference::from) }) .await } @@ -135,7 +141,7 @@ async fn resolve_source_object_key_with( ) -> Result where F: FnOnce(String) -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { let (requested_kind, reference_id) = match source { Model3dGenerationSource::Resource { resource_id } => { @@ -145,13 +151,12 @@ where (ResolvedReferenceKind::Asset, asset_id.as_str()) } }; - let resolved = resolve(reference_id.to_string()).await.map_err(|error| { - // 只有「未登记(404)/ 引用非法(400)」这一类才是“引用不可用”,收敛成同一句 400; - // 403(服务身份校验失败)、409(版本冲突)与 5xx 都是基础设施 / 并发失败, - // 把它们也说成「图片必须是已登记资源」会指错方向,因此继续按原状态码上报。 - match error.status_code() { - StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND => image_source_unavailable(), - _ => error, + let resolved = resolve(reference_id.to_string()).await.map_err(|failure| { + // 只看查询给出的归类:`Unavailable` 才是「未登记 / 跨 owner / 已删除」,收敛成同一句 400; + // 其余(服务身份 403、版本冲突 409、runtime 与连接故障)原样上报,不能按状态码猜。 + match failure.kind { + EditorPointLookupKind::Unavailable => image_source_unavailable(), + EditorPointLookupKind::Other => failure.error, } })?; if resolved.kind != requested_kind { @@ -216,6 +221,13 @@ mod tests { AppError::from_status(status).with_details(json!({ "reason": "probe" })) } + fn probe_failure(kind: EditorPointLookupKind, status: StatusCode) -> EditorPointLookupFailure { + EditorPointLookupFailure { + kind, + error: probe_error(status), + } + } + fn resolved(kind: ResolvedReferenceKind, object_key: Option<&str>) -> ResolvedReference { ResolvedReference { kind, @@ -306,38 +318,41 @@ mod tests { ); } - /// 未登记(404)与引用非法(400)收敛成同一句 400;403 / 409 与 5xx 一律保留原状态码。 + /// 只有查询明确给出的「引用不可用」才收敛成同一句 400;其余一律保留原错误。 #[tokio::test] async fn image_source_resolution_only_collapses_unavailable_references() { let source = Model3dGenerationSource::Resource { resource_id: "res-1".to_string(), }; - for unavailable in [StatusCode::BAD_REQUEST, StatusCode::NOT_FOUND] { - let collapsed = resolve_source_object_key_with(&source, |_| async move { - Err(probe_error(unavailable)) - }) - .await - .expect_err("引用不可用必须收敛"); - assert_eq!(collapsed.status_code(), StatusCode::BAD_REQUEST); - assert_eq!( - reason_of(&collapsed), - Some("model3d-image-source-unavailable") - ); - } + let collapsed = resolve_source_object_key_with(&source, |_| async move { + Err(probe_failure( + EditorPointLookupKind::Unavailable, + StatusCode::NOT_FOUND, + )) + }) + .await + .expect_err("引用不可用必须收敛"); + assert_eq!(collapsed.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + reason_of(&collapsed), + Some("model3d-image-source-unavailable") + ); - // 403 来自 spacetime runtime 的服务身份校验、409 来自并发写入:都不是用户的引用写错了, - // 收敛成 400 会把基础设施故障说成「图片必须是已登记资源」。 + // 400 / 404 里混着 runtime 故障与通用过程错误:归类是 Other 时不得改写成「引用不可用」, + // 否则会把服务端问题说成「图片必须是已登记资源」。 for kept_status in [ + StatusCode::BAD_REQUEST, + StatusCode::NOT_FOUND, StatusCode::FORBIDDEN, StatusCode::CONFLICT, StatusCode::INTERNAL_SERVER_ERROR, ] { let kept = resolve_source_object_key_with(&source, |_| async move { - Err(probe_error(kept_status)) + Err(probe_failure(EditorPointLookupKind::Other, kept_status)) }) .await - .expect_err("基础设施 / 并发故障必须继续报错"); + .expect_err("非引用不可用必须继续报原错"); assert_eq!(kept.status_code(), kept_status); assert_eq!(reason_of(&kept), Some("probe")); }