From ffee739774fc038838c7b24e7c41db6e6500a016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 16:16:27 +0800 Subject: [PATCH] =?UTF-8?q?platform-tripo=20=E6=9E=84=E9=80=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=97=B6=E6=8B=92=E7=BB=9D=E9=9B=B6=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=B8=8E=E7=A9=BA=E7=99=BD=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TripoSettings::new 改为返回 Result:request_timeout 必须大于零,api_key / base_url 不能是空白 - 新增 TripoSettings::validate 与 TripoError::Configuration,字段是公开的,因此 TripoProviderClient::new 在使用前再校验一次 - 拒绝零超时的理由是同一个值存在两种语义:tokio::time::timeout 立即超时,reqwest 却当成不超时 - api-server 构造入口把构造失败按既有 503 口径映射,并补全 TripoError 的匹配分支 - smoke 示例与 platform-tripo 测试跟进新的构造签名,新增零超时 / 空白凭据用例 --- .../crates/api-server/src/tripo3d/errors.rs | 10 ++ .../crates/api-server/src/tripo3d/provider.rs | 7 +- .../examples/tripo_generation_smoke.rs | 2 +- .../platform-tripo/src/common/client.rs | 6 +- .../platform-tripo/src/common/config.rs | 94 ++++++++++++++++++- .../crates/platform-tripo/src/common/error.rs | 8 ++ 6 files changed, 121 insertions(+), 6 deletions(-) diff --git a/server-rs/crates/api-server/src/tripo3d/errors.rs b/server-rs/crates/api-server/src/tripo3d/errors.rs index 6b5b5e871..eb3765fc6 100644 --- a/server-rs/crates/api-server/src/tripo3d/errors.rs +++ b/server-rs/crates/api-server/src/tripo3d/errors.rs @@ -102,6 +102,16 @@ pub(crate) fn map_provider_error(error: TripoError) -> AppError { "3D 生成任务的结果格式不符合预期,未落库。".to_string(), ) } + // 配置问题正常在 `provider.rs` 就按 503 拦下;这里只是把枚举匹配补全, + // 真走到这一步说明有调用方绕过了构造入口,按服务不可用上报。 + TripoError::Configuration { .. } => { + tracing::error!(error = %error, "tripo client misconfigured"); + ( + StatusCode::SERVICE_UNAVAILABLE, + "tripo-invalid-configuration", + "3D 生成服务配置不可用。".to_string(), + ) + } TripoError::Request { .. } | TripoError::Sdk { .. } | TripoError::Api { .. } => { tracing::error!(error = %error, "tripo upstream error"); ( diff --git a/server-rs/crates/api-server/src/tripo3d/provider.rs b/server-rs/crates/api-server/src/tripo3d/provider.rs index b5f977afd..f9251b230 100644 --- a/server-rs/crates/api-server/src/tripo3d/provider.rs +++ b/server-rs/crates/api-server/src/tripo3d/provider.rs @@ -28,14 +28,17 @@ pub(crate) fn tripo_settings(config: &AppConfig) -> Result= 1ms,构造器在这里不会再失败; + // 仍然按 503 映射,保持「配置问题在扣费与入队之前失败」的既有口径。 + TripoSettings::new( api_key.to_string(), base_url.to_string(), Duration::from_millis(config.tripo_request_timeout_ms.max(1)), // 重试次数直接决定产物下载的挂起时长,配置异常时封顶到 10 次。 config.tripo_retries.min(10), TRIPO_USER_AGENT.to_string(), - )) + ) + .map_err(map_provider_client_error) } pub(crate) fn tripo_provider_client(config: &AppConfig) -> Result { diff --git a/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs b/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs index 03e9a6ab6..760233286 100644 --- a/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs +++ b/server-rs/crates/platform-tripo/examples/tripo_generation_smoke.rs @@ -43,7 +43,7 @@ async fn main() -> Result<(), Box> { Duration::from_secs(60), 2, "genarrative-tripo-smoke/1".to_string(), - ))?; + )?)?; let text = client .submit_text_to_model(&Model3dTextToModelParams { diff --git a/server-rs/crates/platform-tripo/src/common/client.rs b/server-rs/crates/platform-tripo/src/common/client.rs index ac51b805d..3bf66844f 100644 --- a/server-rs/crates/platform-tripo/src/common/client.rs +++ b/server-rs/crates/platform-tripo/src/common/client.rs @@ -19,6 +19,9 @@ pub struct TripoProviderClient { impl TripoProviderClient { pub fn new(settings: TripoSettings) -> Result { + // 字段是公开的,结构体字面量能绕过 `TripoSettings::new`;真正发起请求的这一端 + // 再校验一次,零超时 / 空白凭据就不会拖到第一次产物下载才以误导性错误暴露。 + settings.validate()?; let artifact_client = reqwest::Client::builder() .user_agent(settings.user_agent.clone()) // 产物是几十 MB 的流,设总超时会把「还在正常下载」判成失败,也会让重试从头重传; @@ -222,7 +225,8 @@ mod tests { STALL_TIMEOUT, 0, "genarrative-test-tripo/1".to_string(), - ); + ) + .expect("测试配置必须合法"); TripoProviderClient::new(settings).expect("测试配置必须能建出 client") } diff --git a/server-rs/crates/platform-tripo/src/common/config.rs b/server-rs/crates/platform-tripo/src/common/config.rs index c874e44c2..66bc0548b 100644 --- a/server-rs/crates/platform-tripo/src/common/config.rs +++ b/server-rs/crates/platform-tripo/src/common/config.rs @@ -2,6 +2,8 @@ use std::time::Duration; use tripo3d_sdk::ClientOptions; +use super::TripoError; + #[derive(Clone)] pub struct TripoSettings { pub api_key: String, @@ -24,20 +26,54 @@ impl std::fmt::Debug for TripoSettings { } impl TripoSettings { + /// 构造配置;零超时或空白 `api_key` / `base_url` 属于调用方配置错误,直接在这里失败。 + /// + /// 零超时必须被拒绝,因为同一个值在两条链路里含义相反:`tokio::time::timeout` 认为 + /// 它「已经超时」,`reqwest` 却把它解释成「不超时」;放它进来只会得到「产物下载 + /// stalled」这种指着错误方向的报错,而不是一眼看出是配置写错了。 pub fn new( api_key: String, base_url: String, request_timeout: Duration, retries: u32, user_agent: String, - ) -> Self { - Self { + ) -> Result { + let settings = Self { api_key, base_url, request_timeout, retries, user_agent, + }; + settings.validate()?; + Ok(settings) + } + + /// 校验配置自洽。 + /// + /// 字段都是公开的,调用方可以用结构体字面量绕过 [`TripoSettings::new`]; + /// [`crate::TripoProviderClient::new`] 会再校验一次,保证真正发起请求的那一端 + /// 拿到的永远是合法配置。 + pub fn validate(&self) -> Result<(), TripoError> { + if self.api_key.trim().is_empty() { + return Err(TripoError::Configuration { + field: "api_key", + message: "api_key must not be blank".to_string(), + }); } + if self.base_url.trim().is_empty() { + return Err(TripoError::Configuration { + field: "base_url", + message: "base_url must not be blank".to_string(), + }); + } + if self.request_timeout.is_zero() { + return Err(TripoError::Configuration { + field: "request_timeout", + message: "request_timeout must be greater than zero".to_string(), + }); + } + Ok(()) } pub(crate) fn client_options(&self) -> ClientOptions { @@ -50,3 +86,57 @@ impl TripoSettings { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(request_timeout: Duration, api_key: &str, base_url: &str) -> TripoSettings { + TripoSettings { + api_key: api_key.to_string(), + base_url: base_url.to_string(), + request_timeout, + retries: 2, + user_agent: "genarrative-test-tripo/1".to_string(), + } + } + + #[test] + fn zero_timeout_and_blank_credentials_are_rejected() { + for (label, candidate) in [ + ( + "零超时", + settings(Duration::ZERO, "key", "https://example.com"), + ), + ( + "空密钥", + settings(Duration::from_secs(1), "", "https://example.com"), + ), + ( + "空白密钥", + settings(Duration::from_secs(1), " ", "https://example.com"), + ), + ("空地址", settings(Duration::from_secs(1), "key", "")), + ("空白地址", settings(Duration::from_secs(1), "key", " \t")), + ] { + let error = candidate + .validate() + .expect_err(&format!("{label} 必须被拒绝")); + assert!(matches!(error, TripoError::Configuration { .. }), "{error}"); + assert!(!error.is_retryable(), "{label} 不是可重试错误"); + } + } + + #[test] + fn new_returns_ready_to_use_settings() { + let settings = TripoSettings::new( + "key".to_string(), + "https://example.com".to_string(), + Duration::from_secs(5), + 1, + "genarrative-test-tripo/1".to_string(), + ) + .expect("合法配置必须构造成功"); + assert_eq!(settings.request_timeout, Duration::from_secs(5)); + } +} diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs index fe949a5e6..a4c60119f 100644 --- a/server-rs/crates/platform-tripo/src/common/error.rs +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -103,6 +103,11 @@ pub enum TripoError { task_id: String, message: String, }, + /// 客户端配置问题:字段由调用方提供,与请求参数、provider 响应都无关。 + Configuration { + field: &'static str, + message: String, + }, Sdk { message: String, }, @@ -143,6 +148,9 @@ impl fmt::Display for TripoError { Self::OutputSchema { task_id, message } => { write!(f, "Tripo task {task_id} output schema error: {message}") } + Self::Configuration { field, message } => { + write!(f, "Tripo configuration error [{field}]: {message}") + } Self::Sdk { message } => write!(f, "Tripo SDK error: {message}"), } }