From e20ff2e89e03214157a34a00d5c820cf8f10b984 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 21:06:04 +0800 Subject: [PATCH] =?UTF-8?q?provider=20=E6=8A=A5=20unknown=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=8A=B6=E6=80=81=E6=97=B6=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E8=AF=95=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=88=A4=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform-tripo:`TaskStatus::Unknown` 不再归一成 `OutputSchema` 终态失败,改成新的 `TripoError::TaskStatusUnknown`,并把它的可重试性判为真(SDK 自己也没把 unknown 算进终态) - platform-tripo:补用例——provider 回 `"status":"unknown"` 时必须返回可重试的 `TaskStatusUnknown`,不能当成正常快照 - api-server:错误映射补 `TaskStatusUnknown` 分支(502 + 固定文案,轮询侧早已按可重试继续查到截止时间) - api-server worker:状态查询遇到可重试错误时补一条 warn 日志,便于区分「一直是 unknown」和「一次网络抖动」 - docs:Provider 集成方案写明 unknown 按可重试处理及其理由 --- ...¯方案】Tripo 3D模型Provider集成-2026-09-18.md | 2 +- .../crates/api-server/src/tripo3d/errors.rs | 9 ++++ .../crates/api-server/src/tripo3d/worker.rs | 3 ++ .../platform-tripo/src/common/client.rs | 41 +++++++++++++++++++ .../crates/platform-tripo/src/common/error.rs | 14 +++++++ .../platform-tripo/src/common/mapping.rs | 7 ++-- 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md index 70c6f8b4a..efdc07ffc 100644 --- a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md +++ b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md @@ -14,7 +14,7 @@ ## 边界与状态 -`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;未知状态返回结构化错误。 +`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;`unknown` 不在 SDK 的终态集合里,因此按「这次看不懂」处理,返回可重试的 `TripoError::TaskStatusUnknown`,由轮询侧继续查到截止时间 —— 判成终态失败不可恢复,而且 provider 以后新增状态时会把仍在跑的任务整批误杀。 provider task 结果与产品资源结果分离。provider adapter 不生成 `resourceId` 或 `assetId`;未来应用层在资源持久化后再构造带资源 ID 的产品 DTO。模型 URL 被视为临时 provider 引用,下载由显式方法完成。`TripoUrl` 的 `Debug` 与 `redacted()` 只输出 scheme、host 与 path,隐藏可能带签名的 query 与 fragment,并保留命中的分隔符(`?` 或 `#`),不把 fragment 显示成 query;带 userinfo 的地址在解析阶段直接拒绝;主机必须是公网地址,回环、内网、链路本地、组播与保留段(含云元数据地址 `169.254.169.254`)一律拒收,域名层面拦住 `localhost` 及其子域。域名解析出的真实地址在这一层看不见,域名指向内网的情况要靠连接层处理,属已知限制。完整 URL 仅通过 `as_str()` 显式读取;`TripoUrl` 不实现 `Display`,避免 `{}` 这类通用格式化把带签名的完整地址写进日志,冒烟示例同样只打印脱敏后的地址。 diff --git a/server-rs/crates/api-server/src/tripo3d/errors.rs b/server-rs/crates/api-server/src/tripo3d/errors.rs index 4266078d6..94f6ccfa3 100644 --- a/server-rs/crates/api-server/src/tripo3d/errors.rs +++ b/server-rs/crates/api-server/src/tripo3d/errors.rs @@ -144,6 +144,15 @@ pub(crate) fn map_provider_error(error: TripoError) -> AppError { "3D 生成任务的结果格式不符合预期,未落库。".to_string(), ) } + // 轮询侧按可重试继续查到截止时间,真走到这里说明预估的时间已经用尽。 + TripoError::TaskStatusUnknown { .. } => { + tracing::error!(error = %error, "tripo task status is unrecognized"); + ( + StatusCode::BAD_GATEWAY, + "tripo-task-status-unknown", + "3D 生成服务的任务状态暂时无法识别,请稍后重试。".to_string(), + ) + } // 配置问题正常在 `provider.rs` 就按 503 拦下;这里只是把枚举匹配补全, // 真走到这一步说明有调用方绕过了构造入口,按服务不可用上报。 TripoError::Configuration { .. } => { diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index f70e9d122..54fa60c9b 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -252,6 +252,9 @@ async fn poll_until_terminal( // 查询本身已经带过一轮退避重试;仍是瞬时故障时继续按轮询间隔查到预算用尽, // 不因为一次 provider 抖动就作废这笔已经提交、不能重来的任务。 Err(error) if error.is_retryable() => { + // 包括 provider 报的 `unknown` 状态:继续查到截止时间,日志留痕便于区分 + // 「一直是 unknown」和「一次网络抖动」。 + tracing::warn!(error = %error, "3D 生成任务状态查询遇到可重试错误,继续轮询"); let remaining = provider_deadline.saturating_duration_since(Instant::now()); tokio::time::sleep(MODEL3D_POLL_INTERVAL.min(remaining)).await; continue; diff --git a/server-rs/crates/platform-tripo/src/common/client.rs b/server-rs/crates/platform-tripo/src/common/client.rs index f6f31528d..f34be5632 100644 --- a/server-rs/crates/platform-tripo/src/common/client.rs +++ b/server-rs/crates/platform-tripo/src/common/client.rs @@ -522,6 +522,47 @@ mod tests { assert_eq!(snapshot.status, Model3dTaskStatus::Running); } + /// provider 报 `unknown` 状态:按可重试返回,不判终态失败(评审 #57)。 + #[tokio::test] + async fn task_status_query_treats_unknown_status_as_retryable() { + let addr = spawn_mock_server(|mut socket| async move { + read_request(&mut socket).await; + let body = serde_json::json!({ + "code": 0, + "data": { + "task_id": "task-1", + "type": "text_to_model", + "status": "unknown", + "progress": 10 + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; + }) + .await; + + let error = test_client_with_base_url(&format!("http://{addr}"), 0) + .get_task(&TripoTaskHandle { + task_id: "task-1".to_string(), + }) + .await + .expect_err("认不出来的状态不能当成正常快照返回"); + assert!( + matches!(error, TripoError::TaskStatusUnknown { .. }), + "{error:?}" + ); + assert!( + error.is_retryable(), + "unknown 是暂时的看不懂,必须可重试:{error}" + ); + } + /// 404 是确定性失败:只打一次请求就返回,不按退避重试。 #[tokio::test] async fn task_status_query_does_not_retry_deterministic_failures() { diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs index 551ef1e07..728da330a 100644 --- a/server-rs/crates/platform-tripo/src/common/error.rs +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -108,6 +108,14 @@ pub enum TripoError { task_id: String, message: String, }, + /// provider 返回了 `unknown` 这一档任务状态。 + /// + /// SDK 自己也不把 `unknown` 算作终态,因此这里按「暂时看不懂」处理,而不是终态失败: + /// Tripo job 固定只尝试一次、判死不可恢复,而 provider 以后新增状态时,旧代码会把 + /// 仍在跑的任务整批判失败。归成可重试后,轮询侧会继续查到截止时间(评审 #57)。 + TaskStatusUnknown { + task_id: String, + }, /// 客户端配置问题:字段由调用方提供,与请求参数、provider 响应都无关。 Configuration { field: &'static str, @@ -162,6 +170,9 @@ impl fmt::Display for TripoError { Self::OutputSchema { task_id, message } => { write!(f, "Tripo task {task_id} output schema error: {message}") } + Self::TaskStatusUnknown { task_id } => { + write!(f, "Tripo task {task_id} reported the unknown task status") + } Self::Configuration { field, message } => { write!(f, "Tripo configuration error [{field}]: {message}") } @@ -414,6 +425,9 @@ impl TripoError { match self { Self::Api { status, .. } => matches!(status, Some(408 | 425 | 429 | 500..=599)), Self::Request { retryable, .. } => *retryable, + // `unknown` 是「这次看不懂」,下次查询可能就看得懂;终态失败不可恢复, + // 所以判成可重试(评审 #57)。 + Self::TaskStatusUnknown { .. } => true, _ => false, } } diff --git a/server-rs/crates/platform-tripo/src/common/mapping.rs b/server-rs/crates/platform-tripo/src/common/mapping.rs index 2055cd4c2..8264af87c 100644 --- a/server-rs/crates/platform-tripo/src/common/mapping.rs +++ b/server-rs/crates/platform-tripo/src/common/mapping.rs @@ -66,10 +66,11 @@ fn map_status(status: TaskStatus, task_id: &str) -> Result Ok(Model3dTaskStatus::Failed), TaskStatus::Cancelled => Ok(Model3dTaskStatus::Cancelled), TaskStatus::Expired => Ok(Model3dTaskStatus::Expired), - // 与其它映射失败一致带上 task id:provider 异常要能和具体任务对上。 - TaskStatus::Unknown => Err(TripoError::OutputSchema { + // `unknown` 是「这次看不懂」,不是「任务结束了」:SDK 自己也没把它算进终态。 + // 按可重试错误返回(评审 #57),由轮询侧按轮询间隔继续查到截止时间——判成终态 + // 就等于把一笔已经提交、已经计费的任务永久作废,provider 以后新增状态时更会成批误杀。 + TaskStatus::Unknown => Err(TripoError::TaskStatusUnknown { task_id: task_id.into(), - message: "Tripo returned unknown task status".into(), }), } }