图生 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:
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user