图生 3D 的引用预检改为按归类信号收敛,不再拿状态码猜

- 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 不等于用户输入错」一条,记下这条口径与新增模块文案时要同步的清单
This commit is contained in:
2026-09-24 19:55:21 +08:00
parent d04f74bd34
commit 93ef6cebf3
3 changed files with 78 additions and 39 deletions
@@ -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 模型」入口)看不到,用户反馈成「工具栏变成横向可滚动的了」「是不是限宽了」。
@@ -4336,21 +4336,36 @@ pub(crate) async fn resolve_editor_reference_record_by_id_for_owner(
owner_user_id: &str,
reference_id: &str,
) -> Result<EditorReferenceRecord, AppError> {
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<EditorReferenceRecord, EditorPointLookupFailure> {
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(
@@ -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<String, AppError> {
// 生产路径只走「按 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<F, Fut>(
) -> Result<String, AppError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<ResolvedReference, AppError>>,
Fut: std::future::Future<Output = Result<ResolvedReference, EditorPointLookupFailure>>,
{
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"));
}