落点预检改为按归类信号收敛,服务端没配好不再被说成「落点选错了」

- 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 与「模型定价服务身份尚未初始化」不在不可用清单里
This commit is contained in:
2026-09-24 19:53:38 +08:00
parent 453e351fc6
commit 14e72379fc
2 changed files with 165 additions and 22 deletions
@@ -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!(
@@ -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`。