From 14e72379fce95bb656e0afa3154ba313026d51e1 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:53:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E7=82=B9=E9=A2=84=E6=A3=80=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=89=E5=BD=92=E7=B1=BB=E4=BF=A1=E5=8F=B7=E6=94=B6?= =?UTF-8?q?=E6=95=9B=EF=BC=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=B2=A1=E9=85=8D?= =?UTF-8?q?=E5=A5=BD=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AF=B4=E6=88=90=E3=80=8C?= =?UTF-8?q?=E8=90=BD=E7=82=B9=E9=80=89=E9=94=99=E4=BA=86=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server:新增定点查询失败归类 `EditorPointLookupKind` / `EditorPointLookupFailure` 与 `classify_editor_point_lookup_failure`,只把「目标不存在 / 不属于当前账号」的固定文案认成用户侧不可用,其余(服务身份 403、定价身份未初始化、版本冲突、runtime 与连接故障)原样带出 - api-server tripo3d/target.rs:`collapse_target_unavailable` 改为只看归类,不再按 `is_client_error()` 分档,落点预检的模块注释同步 - 用例:target 侧覆盖「Unavailable 收敛、403/400/409/5xx 一律保留」;editor_project 侧新增归类用例,钉住运行时服务无权的 403 与「模型定价服务身份尚未初始化」不在不可用清单里 --- .../crates/api-server/src/editor_project.rs | 120 ++++++++++++++++++ .../crates/api-server/src/tripo3d/target.rs | 67 ++++++---- 2 files changed, 165 insertions(+), 22 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 81b720402..3cfc17522 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -13312,6 +13312,74 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError } } +/// 定点查一条记录(引用 / 落点)的失败归类。 +/// +/// 只有 [`EditorPointLookupKind::Unavailable`] 是「目标不存在或不属于当前账号」这类用户问题, +/// 调用方才可以把它改写成自己的对外文案;服务身份 403、并发 409、runtime 与连接故障都属于 +/// [`EditorPointLookupKind::Other`],一律带原样映射后的错误,不允许按状态码二次猜测。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EditorPointLookupKind { + Unavailable, + Other, +} + +/// 归类结果:`error` 与 [`map_editor_project_error`] 的输出逐字段一致,因此不改写归类时 +/// 其它调用方的对外行为。 +pub(crate) struct EditorPointLookupFailure { + pub(crate) kind: EditorPointLookupKind, + pub(crate) error: AppError, +} + +impl EditorPointLookupFailure { + pub(crate) fn other(error: AppError) -> Self { + Self { + kind: EditorPointLookupKind::Other, + error, + } + } + + pub(crate) fn into_error(self) -> AppError { + self.error + } +} + +/// 模块在「按主键查一条记录」路径上表示「不存在 / 不属于当前账号」的固定文案。 +/// +/// 只有命中这里的目标才是用户侧的「不可用」;`当前 identity 无权调用模型生成运行时服务`、 +/// `模型定价服务身份尚未初始化`、版本冲突与连接故障都不在此列,必须原样上报。 +const EDITOR_POINT_LOOKUP_UNAVAILABLE_MARKERS: &[&str] = &[ + "编辑器引用不存在", + "编辑器资源引用不属于当前用户", + "编辑器素材引用不属于当前用户", + "图片画布工程不存在", + "素材文件夹不存在", + "无权访问该图片画布工程", + "无权访问该素材文件夹", + "无权访问该画布资源", + "无权访问该素材", + "默认素材文件夹不属于当前 owner", +]; + +/// 把定点查询的原始失败归类:先认「不可用」文案,其余统一映射后再交给调用方。 +pub(crate) fn classify_editor_point_lookup_failure( + error: SpacetimeClientError, +) -> EditorPointLookupFailure { + let kind = match &error { + SpacetimeClientError::Procedure(message) + if EDITOR_POINT_LOOKUP_UNAVAILABLE_MARKERS + .iter() + .any(|marker| message.contains(marker)) => + { + EditorPointLookupKind::Unavailable + } + _ => EditorPointLookupKind::Other, + }; + EditorPointLookupFailure { + kind, + error: map_editor_project_error(error), + } +} + pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", @@ -14418,6 +14486,58 @@ mod tests { /// 3D 没有预览图时封面留空:模型文件(.glb)不是图片,拿它顶封面只会得到裂图。 /// 这是唯一允许「有对象但 imageSrc 为空」的类别,前端按空值渲染占位。 + /// 定点查询的失败归类只看文案清单:目标不存在 / 不属于当前账号才是用户侧的「不可用」, + /// 服务身份 403、并发冲突与 runtime 故障都必须留在 `Other`,调用方不得改写它们。 + #[test] + fn editor_point_lookup_classification_separates_unavailable_from_infrastructure() { + for message in [ + "编辑器引用不存在", + "编辑器资源引用不属于当前用户", + "编辑器素材引用不属于当前用户", + "图片画布工程不存在", + "素材文件夹不存在", + "无权访问该图片画布工程", + "无权访问该素材文件夹", + "默认素材文件夹不属于当前 owner", + ] { + let failure = classify_editor_point_lookup_failure(SpacetimeClientError::Procedure( + message.to_string(), + )); + assert_eq!( + failure.kind, + EditorPointLookupKind::Unavailable, + "{message}" + ); + } + + for (status, message) in [ + ( + StatusCode::FORBIDDEN, + "当前 identity 无权调用模型生成运行时服务", + ), + (StatusCode::BAD_REQUEST, "模型定价服务身份尚未初始化"), + (StatusCode::CONFLICT, "版本冲突:写入被并发更新打断"), + ( + StatusCode::BAD_REQUEST, + "编辑器引用 ID 同时命中项目资源与素材,无法唯一解析", + ), + ] { + let failure = classify_editor_point_lookup_failure(SpacetimeClientError::Procedure( + message.to_string(), + )); + assert_eq!(failure.kind, EditorPointLookupKind::Other, "{message}"); + assert_eq!(failure.error.status_code(), status, "{message}"); + } + + // 连接 / runtime 故障同样不得被归成用户侧「不可用」。 + let runtime = classify_editor_point_lookup_failure(SpacetimeClientError::Runtime( + "runtime exploded".to_string(), + )); + assert_eq!(runtime.kind, EditorPointLookupKind::Other); + let timeout = classify_editor_point_lookup_failure(SpacetimeClientError::ConnectDropped); + assert_eq!(timeout.kind, EditorPointLookupKind::Other); + } + #[test] fn editor_client_media_projection_leaves_model3d_cover_empty_without_preview() { assert_eq!( diff --git a/server-rs/crates/api-server/src/tripo3d/target.rs b/server-rs/crates/api-server/src/tripo3d/target.rs index c27b162d6..efe068703 100644 --- a/server-rs/crates/api-server/src/tripo3d/target.rs +++ b/server-rs/crates/api-server/src/tripo3d/target.rs @@ -10,13 +10,20 @@ //! 而落点只在 3D 打开素材库入口后才需要(默认目录由落库 procedure 自己归一)。 //! //! 跨 owner、已删除与不存在收敛成同一句 400,避免把「别人是否存在某个 ID」变成可探测信息; -//! 只有基础设施故障保留原状态码。worker 落库时会再确认同一事实。 +//! 判据是查询给出的 [`EditorPointLookupFailure`] 归类信号——服务身份 403(运行时服务身份 +//! 没配好)、并发 409 与 5xx 都原样上报。worker 落库时会再确认同一事实。 use axum::http::StatusCode; use serde_json::json; use spacetime_client::editor_project::EditorGenerationTargetPreflightRecordInput; -use crate::{editor_project::map_editor_project_error, http_error::AppError, state::AppState}; +use crate::{ + editor_project::{ + EditorPointLookupFailure, EditorPointLookupKind, classify_editor_point_lookup_failure, + }, + http_error::AppError, + state::AppState, +}; use super::job::Model3dJobTarget; use super::provider::TRIPO_PROVIDER; @@ -42,8 +49,8 @@ pub(crate) async fn preflight_generation_target( }) .await .map(|_| ()) - .map_err(map_editor_project_error) - .map_err(|error| collapse_target_unavailable(error, field)) + .map_err(classify_editor_point_lookup_failure) + .map_err(|failure| collapse_target_unavailable(failure, field)) } /// 平坦落点 → 定点预检入参:返回(项目 ID、素材夹 ID)。 @@ -80,12 +87,15 @@ fn target_unavailable_field( } } -/// 未登记 / 跨 owner / 已删除都收敛成同一句 400;基础设施故障保留原状态码。 -fn collapse_target_unavailable(error: AppError, field: &'static str) -> AppError { - if error.status_code().is_client_error() { - target_unavailable(field) - } else { - error +/// 未登记 / 跨 owner / 已删除都收敛成同一句 400;其余失败一律保留原错误。 +/// +/// 只看归类信号:`当前 identity 无权调用模型生成运行时服务` 的 403 与 +/// `模型定价服务身份尚未初始化` 的 400 都是服务端没配好,按状态码分档会把它们误报成 +/// 「你的落点选错了」(见 `classify_editor_point_lookup_failure` 的文案清单)。 +fn collapse_target_unavailable(failure: EditorPointLookupFailure, field: &'static str) -> AppError { + match failure.kind { + EditorPointLookupKind::Unavailable => target_unavailable(field), + EditorPointLookupKind::Other => failure.into_error(), } } @@ -112,11 +122,14 @@ mod tests { assert_eq!(details["field"], json!("projectId")); } - /// 跨 owner / 不存在(4xx)对外只有一种说法;基础设施故障不能被误报成用户问题。 + /// 跨 owner / 不存在对外只有一种说法;服务端没配好(403 / 400)不能被误报成用户问题。 #[test] - fn only_client_errors_collapse_into_target_unavailable() { + fn only_unavailable_lookups_collapse_into_target_unavailable() { let collapsed = collapse_target_unavailable( - AppError::from_status(StatusCode::NOT_FOUND), + EditorPointLookupFailure { + kind: EditorPointLookupKind::Unavailable, + error: AppError::from_status(StatusCode::NOT_FOUND), + }, "assetFolderId", ); assert_eq!( @@ -124,15 +137,25 @@ mod tests { target_unavailable("assetFolderId").details() ); - let preserved = collapse_target_unavailable( - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR), - "assetFolderId", - ); - assert_eq!(preserved.status_code(), StatusCode::INTERNAL_SERVER_ERROR); - assert_ne!( - preserved.details(), - target_unavailable("assetFolderId").details() - ); + for kept in [ + StatusCode::FORBIDDEN, + StatusCode::BAD_REQUEST, + StatusCode::CONFLICT, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + let preserved = collapse_target_unavailable( + EditorPointLookupFailure { + kind: EditorPointLookupKind::Other, + error: AppError::from_status(kept), + }, + "assetFolderId", + ); + assert_eq!(preserved.status_code(), kept); + assert_ne!( + preserved.details(), + target_unavailable("assetFolderId").details() + ); + } } /// 平坦落点收敛成定点预检入参:只做 trim,两个都给就都传,都不给返回 `None`。