落点预检改为按归类信号收敛,服务端没配好不再被说成「落点选错了」
- 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:
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user