diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index a2c41ccda..d3c684d57 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -46,62 +46,39 @@ pub(crate) async fn segment_image_with_aliyun_matting( /// 把 platform-matting 的错误映射成审计友好的 AppError。 /// -/// 关键区分:`InvalidConfig` / `InvalidRequest` / `Sign` 都是发请求前的本地预检失败 -/// (客户端未配置、图片解码失败、尺寸过小、签名构造失败),此时根本没调用阿里云,必须 -/// 标记 `externalCallAttempted=false`、`transport=false`,否则会被误写成可重试的供应商 -/// transport 故障审计。只有 `Upstream` 才是真实发生过的外部调用失败。 +/// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处 +/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在 +/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是发请求前的本地预检失败, +/// `external_call_attempted()` 为 false,据此跳过外部失败审计。 fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError { let message = error.message(); - match error { - MattingError::Upstream(_) => { - let timeout = aliyun_matting_error_is_timeout(message); - let upstream_status = aliyun_matting_error_http_status(message); - let status = if timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - AppError::from_status(status).with_details(json!({ - "provider": "aliyun-matting", - "message": message, - "timeout": timeout, - "transport": aliyun_matting_error_is_transport(message), - "upstreamStatus": upstream_status, - "latencyMs": latency_ms, - "rawExcerpt": message.chars().take(500).collect::(), - })) - } - MattingError::InvalidConfig(_) | MattingError::InvalidRequest(_) | MattingError::Sign(_) => { - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ - "provider": "aliyun-matting", - "message": message, - "timeout": false, - "transport": false, - "externalCallAttempted": false, - "latencyMs": latency_ms, - "rawExcerpt": message.chars().take(500).collect::(), - })) - } + if !error.external_call_attempted() { + // 本地预检失败(未配置 / 图片解码失败 / 尺寸过小 / 签名构造失败),未触达阿里云。 + return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "aliyun-matting", + "message": message, + "timeout": false, + "transport": false, + "externalCallAttempted": false, + "latencyMs": latency_ms, + "rawExcerpt": message.chars().take(500).collect::(), + })); } -} - -fn aliyun_matting_error_is_timeout(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - normalized.contains("timeout") || normalized.contains("timed out") -} - -fn aliyun_matting_error_http_status(message: &str) -> Option { - let marker = "HTTP "; - let start = message.find(marker)? + marker.len(); - let digits = message[start..] - .chars() - .take_while(|value| value.is_ascii_digit()) - .collect::(); - digits.parse().ok() -} - -fn aliyun_matting_error_is_transport(message: &str) -> bool { - aliyun_matting_error_http_status(message).is_none() + let timeout = error.is_timeout(); + let status = if timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + AppError::from_status(status).with_details(json!({ + "provider": "aliyun-matting", + "message": message, + "timeout": timeout, + "transport": error.is_transport(), + "upstreamStatus": error.upstream_status(), + "latencyMs": latency_ms, + "rawExcerpt": message.chars().take(500).collect::(), + })) } fn aliyun_matting_unconfigured_error() -> AppError { @@ -116,44 +93,6 @@ fn aliyun_matting_unconfigured_error() -> AppError { mod tests { use super::*; - #[test] - fn aliyun_matting_error_http_status_extracts_provider_status() { - assert_eq!( - aliyun_matting_error_http_status( - "通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded" - ), - Some(429) - ); - assert_eq!( - aliyun_matting_error_http_status("下载抠图结果失败(HTTP 403)"), - Some(403) - ); - assert_eq!(aliyun_matting_error_http_status("网络失败"), None); - } - - #[test] - fn aliyun_matting_error_timeout_detects_transport_timeout() { - assert!(aliyun_matting_error_is_timeout( - "通用抠图请求失败:operation timed out" - )); - assert!(aliyun_matting_error_is_timeout( - "GetOssStsToken 请求失败:request timeout" - )); - assert!(!aliyun_matting_error_is_timeout( - "通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image" - )); - } - - #[test] - fn aliyun_matting_error_transport_requires_missing_http_status() { - assert!(aliyun_matting_error_is_transport( - "通用抠图请求失败:dns error" - )); - assert!(!aliyun_matting_error_is_transport( - "通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded" - )); - } - #[test] fn aliyun_matting_unconfigured_error_marks_no_external_call_attempt() { let error = aliyun_matting_unconfigured_error(); @@ -185,15 +124,42 @@ mod tests { } #[test] - fn upstream_failure_still_counts_as_external_call() { - let error = MattingError::Upstream( - "通用抠图请求失败:dns error".to_string(), - ); + fn upstream_transport_failure_maps_to_retryable_transport() { + let error = + MattingError::upstream_transport_error("通用抠图请求失败:dns error".to_string(), false); let mapped = aliyun_matting_failure_to_app_error(&error, 12); assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped)); let details = mapped.details().expect("details present"); - // 无 HTTP 状态的上游失败仍应标记为可重试 transport 故障。 + // 无 HTTP 状态的传输层失败标记为可重试 transport 故障,且不带 upstreamStatus。 + assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(false)); + assert!(details.get("upstreamStatus").is_some_and(|v| v.is_null())); + } + + #[test] + fn upstream_timeout_failure_maps_to_gateway_timeout() { + let error = + MattingError::upstream_transport_error("通用抠图请求失败:timed out".to_string(), true); + let mapped = aliyun_matting_failure_to_app_error(&error, 7); + + assert_eq!(mapped.status_code(), StatusCode::GATEWAY_TIMEOUT); + let details = mapped.details().expect("details present"); + assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(true)); assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true)); } + + #[test] + fn upstream_http_status_failure_carries_status_without_transport() { + let error = MattingError::upstream_http_error( + "通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded".to_string(), + 429, + ); + let mapped = aliyun_matting_failure_to_app_error(&error, 5); + + assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped)); + let details = mapped.details().expect("details present"); + assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(details.get("upstreamStatus").and_then(|v| v.as_u64()), Some(429)); + } } diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index d99cee091..0df252889 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -143,7 +143,20 @@ pub enum MattingError { InvalidConfig(String), InvalidRequest(String), Sign(String), - Upstream(String), + Upstream(UpstreamFailure), +} + +/// 结构化的上游调用失败分类。协议层归属(是否传输层故障、是否超时、上游 HTTP 状态)由 +/// platform-matting 在错误发生处直接捕获,调用方(api-server BFF)不再从中文 message 反推。 +#[derive(Debug)] +pub struct UpstreamFailure { + message: String, + /// 传输层超时(reqwest `is_timeout`)。 + timeout: bool, + /// 传输层故障:请求已发出但未拿到有效 HTTP 响应(连接失败、读体中断、超时)。 + transport: bool, + /// 上游返回的 HTTP 状态码;`None` 表示没拿到状态(传输层故障或响应体不可用)。 + upstream_status: Option, } impl MattingError { @@ -151,10 +164,65 @@ impl MattingError { match self { Self::InvalidConfig(message) | Self::InvalidRequest(message) - | Self::Sign(message) - | Self::Upstream(message) => message, + | Self::Sign(message) => message, + Self::Upstream(failure) => &failure.message, } } + + /// 是否真正向阿里云发起过外部调用。`InvalidConfig` / `InvalidRequest` / `Sign` 都是发请求前的 + /// 本地预检失败,未触达上游。 + pub fn external_call_attempted(&self) -> bool { + matches!(self, Self::Upstream(_)) + } + + /// 上游调用是否为传输层超时。 + pub fn is_timeout(&self) -> bool { + matches!(self, Self::Upstream(failure) if failure.timeout) + } + + /// 上游调用是否为传输层故障(未拿到有效 HTTP 响应)。 + pub fn is_transport(&self) -> bool { + matches!(self, Self::Upstream(failure) if failure.transport) + } + + /// 上游返回的 HTTP 状态码(若有)。 + pub fn upstream_status(&self) -> Option { + match self { + Self::Upstream(failure) => failure.upstream_status, + _ => None, + } + } + + /// 传输层故障构造器:请求已发出但没拿到有效 HTTP 响应(连接、读体、超时)。 + pub fn upstream_transport_error(message: String, timeout: bool) -> Self { + Self::Upstream(UpstreamFailure { + message, + timeout, + transport: true, + upstream_status: None, + }) + } + + /// 上游返回了 HTTP 响应但状态非成功。 + pub fn upstream_http_error(message: String, status: u16) -> Self { + Self::Upstream(UpstreamFailure { + message, + timeout: false, + transport: false, + upstream_status: Some(status), + }) + } + + /// 已拿到 HTTP 响应,但响应体 / 结果不可用(JSON 非法、缺字段、结果图不可解码 / 尺寸不符、 + /// 结果编码失败等)。不是传输层故障,也不归因某个错误状态码。 + pub fn upstream_response_error(message: String, upstream_status: Option) -> Self { + Self::Upstream(UpstreamFailure { + message, + timeout: false, + transport: false, + upstream_status, + }) + } } impl std::fmt::Display for MattingError { @@ -234,17 +302,28 @@ impl MattingClient { .body(payload) .send() .await - .map_err(|error| MattingError::Upstream(format!("通用抠图请求失败:{error}")))?; + .map_err(|error| { + MattingError::upstream_transport_error( + format!("通用抠图请求失败:{error}"), + error.is_timeout(), + ) + })?; let http_status = response.status(); let body_text = response.text().await.map_err(|error| { - MattingError::Upstream(format!("通用抠图响应读取失败:{error}")) + MattingError::upstream_transport_error( + format!("通用抠图响应读取失败:{error}"), + error.is_timeout(), + ) })?; let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| { - MattingError::Upstream(format!( - "通用抠图响应不是合法 JSON:{error};原始响应:{}", - truncate_for_log(&body_text) - )) + MattingError::upstream_response_error( + format!( + "通用抠图响应不是合法 JSON:{error};原始响应:{}", + truncate_for_log(&body_text) + ), + Some(http_status.as_u16()), + ) })?; let request_id = body @@ -263,12 +342,15 @@ impl MattingClient { provider_request_id = request_id.as_deref().unwrap_or("unknown"), "阿里云通用抠图接口返回失败" ); - return Err(MattingError::Upstream(format!( - "通用抠图接口返回失败(HTTP {},Code={}):{}", + return Err(MattingError::upstream_http_error( + format!( + "通用抠图接口返回失败(HTTP {},Code={}):{}", + http_status.as_u16(), + code.unwrap_or("unknown"), + message.unwrap_or("unknown") + ), http_status.as_u16(), - code.unwrap_or("unknown"), - message.unwrap_or("unknown") - ))); + )); } let result_image_url = body @@ -277,10 +359,13 @@ impl MattingClient { .and_then(|value| value.as_str()) .map(|value| value.to_string()) .ok_or_else(|| { - MattingError::Upstream(format!( - "通用抠图响应缺少 Data.ImageURL;原始响应:{}", - truncate_for_log(&body_text) - )) + MattingError::upstream_response_error( + format!( + "通用抠图响应缺少 Data.ImageURL;原始响应:{}", + truncate_for_log(&body_text) + ), + Some(http_status.as_u16()), + ) })?; info!( @@ -337,13 +422,16 @@ impl MattingClient { if !downscaled { if result_image.dimensions() != (source_width, source_height) { - return Err(MattingError::Upstream(format!( - "抠图结果尺寸 {}x{} 与输入 {}x{} 不一致", - result_image.width(), - result_image.height(), - source_width, - source_height - ))); + return Err(MattingError::upstream_response_error( + format!( + "抠图结果尺寸 {}x{} 与输入 {}x{} 不一致", + result_image.width(), + result_image.height(), + source_width, + source_height + ), + None, + )); } return encode_rgba_png(&result_image); } @@ -369,14 +457,17 @@ impl MattingClient { .send() .await .map_err(|error| { - MattingError::Upstream(describe_result_download_transport_error(&error)) + MattingError::upstream_transport_error( + describe_result_download_transport_error(&error), + error.is_timeout(), + ) })?; let status = response.status(); if !status.is_success() { - return Err(MattingError::Upstream(format!( - "下载抠图结果失败(HTTP {})", - status.as_u16() - ))); + return Err(MattingError::upstream_http_error( + format!("下载抠图结果失败(HTTP {})", status.as_u16()), + status.as_u16(), + )); } // 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度, // 逐块累计超阈值立即中断,避免 response.bytes() 一次性分配任意大小响应撑爆内存。 @@ -388,7 +479,10 @@ impl MattingClient { } let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await.map_err(|error| { - MattingError::Upstream(describe_result_download_body_error(&error)) + MattingError::upstream_transport_error( + describe_result_download_body_error(&error), + error.is_timeout(), + ) })? { if bytes.len().saturating_add(chunk.len()) > MAX_RESULT_RESPONSE_BYTES { return Err(result_response_too_large_error()); @@ -457,18 +551,24 @@ impl MattingClient { .send() .await .map_err(|error| { - MattingError::Upstream(format!("上传 VIAPI 临时桶请求失败:{error}")) + MattingError::upstream_transport_error( + format!("上传 VIAPI 临时桶请求失败:{error}"), + error.is_timeout(), + ) })?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); // OSS 签名类错误体会回显 StringToSign(含 x-oss-security-token 明文)、StringToSignBytes // 和 SignatureProvided;脱敏后再记录,避免 STS 临时凭证进审计元数据与日志。 - return Err(MattingError::Upstream(format!( - "上传 VIAPI 临时桶失败(HTTP {}):{}", + return Err(MattingError::upstream_http_error( + format!( + "上传 VIAPI 临时桶失败(HTTP {}):{}", + status.as_u16(), + sanitize_oss_upload_error_body(&body, &sts.security_token) + ), status.as_u16(), - sanitize_oss_upload_error_body(&body, &sts.security_token) - ))); + )); } Ok(target_url) @@ -498,39 +598,59 @@ impl MattingClient { .body(payload) .send() .await - .map_err(|error| MattingError::Upstream(format!("GetOssStsToken 请求失败:{error}")))?; + .map_err(|error| { + MattingError::upstream_transport_error( + format!("GetOssStsToken 请求失败:{error}"), + error.is_timeout(), + ) + })?; let http_status = response.status(); let body_text = response.text().await.map_err(|error| { - MattingError::Upstream(format!("GetOssStsToken 响应读取失败:{error}")) + MattingError::upstream_transport_error( + format!("GetOssStsToken 响应读取失败:{error}"), + error.is_timeout(), + ) })?; let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| { - MattingError::Upstream(format!( - "GetOssStsToken 响应不是合法 JSON:{error};原始响应:{}", - truncate_for_log(&body_text) - )) + MattingError::upstream_response_error( + format!( + "GetOssStsToken 响应不是合法 JSON:{error};原始响应:{}", + truncate_for_log(&body_text) + ), + Some(http_status.as_u16()), + ) })?; if http_status != StatusCode::OK { - return Err(MattingError::Upstream(format!( - "GetOssStsToken 返回失败(HTTP {},Code={}):{}", + return Err(MattingError::upstream_http_error( + format!( + "GetOssStsToken 返回失败(HTTP {},Code={}):{}", + http_status.as_u16(), + body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"), + body.get("Message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ), http_status.as_u16(), - body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"), - body.get("Message") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - ))); + )); } let data = body.get("Data").ok_or_else(|| { - MattingError::Upstream(format!( - "GetOssStsToken 响应缺少 Data;原始响应:{}", - truncate_for_log(&body_text) - )) + MattingError::upstream_response_error( + format!( + "GetOssStsToken 响应缺少 Data;原始响应:{}", + truncate_for_log(&body_text) + ), + Some(http_status.as_u16()), + ) })?; let read_field = |name: &str| -> Result { data.get(name) .and_then(|value| value.as_str()) .map(|value| value.to_string()) .ok_or_else(|| { - MattingError::Upstream(format!("GetOssStsToken 响应缺少 Data.{name}")) + MattingError::upstream_response_error( + format!("GetOssStsToken 响应缺少 Data.{name}"), + Some(http_status.as_u16()), + ) }) }; Ok(ViapiStsToken { @@ -641,14 +761,20 @@ fn encode_rgba_png(image: &image::RgbaImage) -> Result, MattingError> { image.height(), image::ExtendedColorType::Rgba8, ) - .map_err(|error| MattingError::Upstream(format!("编码抠图结果 PNG 失败:{error}")))?; + .map_err(|error| { + MattingError::upstream_response_error( + format!("编码抠图结果 PNG 失败:{error}"), + None, + ) + })?; Ok(encoded) } fn result_response_too_large_error() -> MattingError { - MattingError::Upstream(format!( - "抠图结果响应过大,超过 {MAX_RESULT_RESPONSE_BYTES} 字节上限" - )) + MattingError::upstream_response_error( + format!("抠图结果响应过大,超过 {MAX_RESULT_RESPONSE_BYTES} 字节上限"), + None, + ) } /// 解码待抠图源图时套上尺寸 / 分配上限。源图直接来自请求体 / 生成产物,缺省 @@ -661,8 +787,9 @@ fn decode_source_image(bytes: &[u8]) -> Result Result { - decode_image_within_limits(bytes) - .map_err(|error| MattingError::Upstream(format!("解析抠图结果图片失败:{error}"))) + decode_image_within_limits(bytes).map_err(|error| { + MattingError::upstream_response_error(format!("解析抠图结果图片失败:{error}"), None) + }) } /// 统一的有界解码:尺寸上限 `MAX_DECODE_IMAGE_DIMENSION`、分配上限 @@ -858,6 +985,63 @@ fn truncate_for_log(text: &str) -> String { mod tests { use super::*; + #[test] + fn local_preflight_variants_report_no_external_call() { + for error in [ + MattingError::InvalidConfig("endpoint 为空".to_string()), + MattingError::InvalidRequest("待抠图图片尺寸过小".to_string()), + MattingError::Sign("初始化签名器失败".to_string()), + ] { + assert!(!error.external_call_attempted(), "{}", error.message()); + assert!(!error.is_timeout()); + assert!(!error.is_transport()); + assert_eq!(error.upstream_status(), None); + } + } + + #[test] + fn upstream_transport_error_classifies_as_transport() { + let error = + MattingError::upstream_transport_error("通用抠图请求失败:dns error".to_string(), false); + assert!(error.external_call_attempted()); + assert!(error.is_transport()); + assert!(!error.is_timeout()); + assert_eq!(error.upstream_status(), None); + } + + #[test] + fn upstream_transport_error_preserves_timeout_flag() { + let error = + MattingError::upstream_transport_error("通用抠图请求失败:timed out".to_string(), true); + assert!(error.external_call_attempted()); + assert!(error.is_transport()); + assert!(error.is_timeout()); + } + + #[test] + fn upstream_http_error_carries_status_and_is_not_transport() { + let error = MattingError::upstream_http_error( + "通用抠图接口返回失败(HTTP 429,Code=Throttled)".to_string(), + 429, + ); + assert!(error.external_call_attempted()); + assert!(!error.is_transport()); + assert!(!error.is_timeout()); + assert_eq!(error.upstream_status(), Some(429)); + } + + #[test] + fn upstream_response_error_is_external_but_not_transport() { + let error = MattingError::upstream_response_error( + "抠图结果尺寸不一致".to_string(), + None, + ); + assert!(error.external_call_attempted()); + assert!(!error.is_transport()); + assert!(!error.is_timeout()); + assert_eq!(error.upstream_status(), None); + } + #[test] fn form_body_is_sorted_and_url_encoded() { let mut params = BTreeMap::new();