diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index cf2e9a4e0..a2c41ccda 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -4,6 +4,7 @@ //! 统一由 platform-matting 上传 VIAPI 官方临时桶(1 天自动过期,无需清理)。 use axum::http::StatusCode; +use platform_matting::MattingError; use serde_json::json; use crate::{ @@ -27,23 +28,7 @@ pub(crate) async fn segment_image_with_aliyun_matting( .segment_image_to_transparent_png(image.bytes.as_slice(), &file_name) .await .map_err(|error| { - let message = error.message(); - 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": started_at.elapsed().as_millis() as u64, - "rawExcerpt": message.chars().take(500).collect::(), - })) + aliyun_matting_failure_to_app_error(&error, started_at.elapsed().as_millis() as u64) })?; tracing::info!( provider = "aliyun-matting", @@ -59,6 +44,47 @@ pub(crate) async fn segment_image_with_aliyun_matting( }) } +/// 把 platform-matting 的错误映射成审计友好的 AppError。 +/// +/// 关键区分:`InvalidConfig` / `InvalidRequest` / `Sign` 都是发请求前的本地预检失败 +/// (客户端未配置、图片解码失败、尺寸过小、签名构造失败),此时根本没调用阿里云,必须 +/// 标记 `externalCallAttempted=false`、`transport=false`,否则会被误写成可重试的供应商 +/// transport 故障审计。只有 `Upstream` 才是真实发生过的外部调用失败。 +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::(), + })) + } + } +} + fn aliyun_matting_error_is_timeout(message: &str) -> bool { let normalized = message.to_ascii_lowercase(); normalized.contains("timeout") || normalized.contains("timed out") @@ -134,4 +160,40 @@ mod tests { assert!(!crate::external_api_audit::matting_failure_external_call_attempted(&error)); } + + #[test] + fn local_preflight_failures_do_not_count_as_external_call() { + for error in [ + MattingError::InvalidRequest( + "待抠图图片尺寸 16x16 过小,阿里云通用抠图要求每条边大于 32 像素".to_string(), + ), + MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()), + MattingError::InvalidConfig("endpoint 为空".to_string()), + MattingError::Sign("初始化 OSS V1 签名器失败".to_string()), + ] { + let mapped = aliyun_matting_failure_to_app_error(&error, 3); + + assert!( + !crate::external_api_audit::matting_failure_external_call_attempted(&mapped), + "本地预检失败不应记为外部调用:{}", + error.message() + ); + let details = mapped.details().expect("details present"); + assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(false)); + } + } + + #[test] + fn upstream_failure_still_counts_as_external_call() { + let error = MattingError::Upstream( + "通用抠图请求失败:dns error".to_string(), + ); + 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 故障。 + assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true)); + } }