From 7bce98439e5fec585403db38d4bec3ec1d50392b Mon Sep 17 00:00:00 2001 From: Linghong Date: Sat, 18 Jul 2026 12:00:43 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E5=A4=84=E7=90=86=E5=A4=B1=E8=B4=A5=E8=A2=AB=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=88=90=E5=8F=AF=E9=87=8D=E8=AF=95=205xx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalProcessing 映射为 422 并标记 localProcessing,不再包装为 502。 审计层区分 transport 与 local:无上游状态且非传输故障记 statusClass=local 且 retryable=false。 --- .../crates/api-server/src/aliyun_matting.rs | 57 ++++++++++++- .../crates/api-server/src/editor_project.rs | 2 + .../api-server/src/external_api_audit.rs | 81 +++++++++++++++---- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index 8ae6c96be..e961513e6 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -4,7 +4,7 @@ use axum::http::StatusCode; use platform_matting::MattingError; -use serde_json::json; +use serde_json::{Value, json}; use crate::{ http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState, @@ -49,7 +49,7 @@ pub(crate) async fn segment_image_url_with_aliyun_matting( /// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在 /// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是尚未开始外部调用的本地预检失败; /// URL 链路在 OSS GET 成功后发生的解码、尺寸或其它本地处理失败由 `LocalProcessing` 表示, -/// 仍然需要进入外部失败审计。 +/// 仍然需要进入外部失败审计,但不得包装成可重试的上游 5xx。 fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError { let message = error.message(); if !error.external_call_attempted() { @@ -59,12 +59,28 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> "message": message, "timeout": false, "transport": false, + "localProcessing": false, "externalCallAttempted": false, "failureStage": error.failure_stage(), "latencyMs": latency_ms, "rawExcerpt": message.chars().take(500).collect::(), })); } + // 外部调用已开始,但失败在本地解码 / 校验 / 归一等阶段:要审计,不能标成上游 5xx / 可重试。 + if matches!(error, MattingError::LocalProcessing(_)) { + return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "aliyun-matting", + "message": message, + "timeout": false, + "transport": false, + "localProcessing": true, + "upstreamStatus": Value::Null, + "externalCallAttempted": true, + "failureStage": error.failure_stage(), + "latencyMs": latency_ms, + "rawExcerpt": message.chars().take(500).collect::(), + })); + } let timeout = error.is_timeout(); let status = if timeout { StatusCode::GATEWAY_TIMEOUT @@ -76,6 +92,7 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> "message": message, "timeout": timeout, "transport": error.is_transport(), + "localProcessing": false, "upstreamStatus": error.upstream_status(), "externalCallAttempted": true, "failureStage": error.failure_stage(), @@ -165,7 +182,8 @@ mod tests { let mapped = aliyun_matting_failure_to_app_error(&error, 3); assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped)); - assert_eq!(mapped.status_code(), StatusCode::BAD_GATEWAY); + // 本地处理失败要审计,但 HTTP 包装不得落成可重试 5xx。 + assert_eq!(mapped.status_code(), StatusCode::UNPROCESSABLE_ENTITY); let details = mapped.details().expect("details present"); assert_eq!( details @@ -173,6 +191,19 @@ mod tests { .and_then(|v| v.as_bool()), Some(true) ); + assert_eq!( + details.get("localProcessing").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + details.get("transport").and_then(|v| v.as_bool()), + Some(false) + ); + assert!( + details + .get("upstreamStatus") + .is_none_or(|value| value.is_null()) + ); assert_eq!( details.get("failureStage").and_then(|v| v.as_str()), Some(expected_stage) @@ -184,6 +215,26 @@ mod tests { ), expected_stage ); + assert_eq!( + crate::external_api_audit::matting_failure_audit_status_code(&mapped), + None, + "本地处理失败没有上游 HTTP 状态,不能回退包装码" + ); + let draft = crate::external_api_audit::build_matting_external_api_failure_draft( + "aliyun-matting", + "imageseg.example".to_string(), + "editor-screen-background-removal", + expected_stage, + crate::external_api_audit::matting_failure_audit_status_code(&mapped), + crate::external_api_audit::matting_failure_audit_timeout(&mapped), + crate::external_api_audit::matting_failure_audit_is_transport(&mapped), + crate::external_api_audit::matting_failure_audit_latency_ms(&mapped), + mapped.message().to_string(), + crate::external_api_audit::matting_failure_audit_raw_excerpt(&mapped), + &crate::external_api_audit::ExternalApiAuditContext::default(), + ); + assert_eq!(draft.status_class, Some("local")); + assert!(!draft.retryable); } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 440dd47b7..a87179325 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3467,6 +3467,7 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with "bgfilter_segment", crate::external_api_audit::matting_failure_audit_status_code(&error), crate::external_api_audit::matting_failure_audit_timeout(&error), + crate::external_api_audit::matting_failure_audit_is_transport(&error), crate::external_api_audit::matting_failure_audit_latency_ms(&error), error.message().to_string(), crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), @@ -3552,6 +3553,7 @@ async fn fallback_editor_screen_background_removal( ), crate::external_api_audit::matting_failure_audit_status_code(&error), crate::external_api_audit::matting_failure_audit_timeout(&error), + crate::external_api_audit::matting_failure_audit_is_transport(&error), crate::external_api_audit::matting_failure_audit_latency_ms(&error), error.message().to_string(), crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 691952e73..0039f7b0a 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -159,6 +159,7 @@ pub(crate) async fn record_matting_external_api_failure( failure_stage: &'static str, status_code: Option, timeout: bool, + transport: bool, latency_ms: Option, error_message: String, raw_excerpt: Option, @@ -170,6 +171,7 @@ pub(crate) async fn record_matting_external_api_failure( failure_stage, status_code, timeout, + transport, latency_ms, error_message, raw_excerpt, @@ -178,31 +180,41 @@ pub(crate) async fn record_matting_external_api_failure( record_external_api_failure(state, draft).await; } +/// 构建抠图失败审计 draft。`transport` 必须由调用方从错误结构化字段读取, +/// 不能用 `status_code.is_none()` 反推——本地处理失败同样没有上游 HTTP 状态。 #[allow(clippy::too_many_arguments)] -fn build_matting_external_api_failure_draft( +pub(crate) fn build_matting_external_api_failure_draft( provider: &'static str, endpoint: String, operation: &'static str, failure_stage: &'static str, status_code: Option, timeout: bool, + transport: bool, latency_ms: Option, error_message: String, raw_excerpt: Option, context: &ExternalApiAuditContext, ) -> ExternalApiFailureDraft { - // status_code=None ⟺ statusClass=transport(见 status_class):DNS / 连接重置 / 读体中断 / 超时 - // 这类传输层失败没有上游 HTTP 状态。它们必须与 "transport failures actionable" 语义一致, - // 记为 retryable=true,否则 statusClass=transport 却 retryable=false 会误导告警 / 重试分析。 - let has_transport_error = status_code.is_none(); + // 传输层:无上游 HTTP 状态 + timeout/transport 标记 → statusClass=transport、retryable=true。 + // 本地处理:无上游 HTTP 状态且非 transport → statusClass=local、retryable=false。 + // 不得把「status_code=None」一律当成 transport,否则 LocalProcessing 会污染可重试 5xx 分析。 + let is_transport_failure = timeout || transport; + let resolved_status_class = if is_transport_failure && status_code.is_none() { + "transport" + } else if status_code.is_none() { + "local" + } else { + status_class(status_code) + }; ExternalApiFailureDraft::new(provider, endpoint, operation, failure_stage, error_message) .with_status_code(status_code) - .with_optional_status_class(Some(status_class(status_code))) + .with_optional_status_class(Some(resolved_status_class)) .with_timeout(timeout) .with_retryable(is_retryable_external_api_failure( status_code, timeout, - has_transport_error, + is_transport_failure, )) .with_latency_ms(latency_ms) .with_raw_excerpt(raw_excerpt) @@ -210,21 +222,31 @@ fn build_matting_external_api_failure_draft( } pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option { - error + // 真实上游 HTTP 状态优先;本地处理 / 传输层都没有可写的上游 status。 + if let Some(status) = error .details() .and_then(|details| details.get("upstreamStatus")) .and_then(Value::as_u64) .and_then(|value| u16::try_from(value).ok()) - .or_else(|| { - if matting_failure_audit_is_transport(error) { - None - } else { - Some(error.status_code().as_u16()) - } - }) + { + return Some(status); + } + if matting_failure_audit_is_local_processing(error) || matting_failure_audit_is_transport(error) + { + return None; + } + Some(error.status_code().as_u16()) } -fn matting_failure_audit_is_transport(error: &AppError) -> bool { +pub(crate) fn matting_failure_audit_is_local_processing(error: &AppError) -> bool { + error + .details() + .and_then(|details| details.get("localProcessing")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +pub(crate) fn matting_failure_audit_is_transport(error: &AppError) -> bool { matting_failure_audit_timeout(error) || error .details() @@ -599,7 +621,7 @@ mod tests { #[test] fn matting_failure_draft_marks_non_timeout_transport_retryable() { - // status_code=None、timeout=false 的非超时 transport 失败(DNS / 连接重置 / 读体中断): + // status_code=None、timeout=false、transport=true 的非超时传输故障: // statusClass 必须是 transport 且 retryable=true,否则与 "transport failures actionable" 冲突。 let draft = build_matting_external_api_failure_draft( "bgfilter", @@ -608,6 +630,7 @@ mod tests { "bgfilter_segment", None, false, + true, Some(67), "请求 BgFilter 服务失败:dns error".to_string(), Some("dns error".to_string()), @@ -623,6 +646,29 @@ mod tests { ); } + #[test] + fn matting_failure_draft_marks_local_processing_non_retryable() { + // 本地处理失败没有上游 HTTP 状态,也不是 transport;不得记成可重试 5xx/transport。 + let draft = build_matting_external_api_failure_draft( + "aliyun-matting", + "imageseg.example".to_string(), + "editor-screen-background-removal", + "source_decode", + None, + false, + false, + Some(3), + "解析待抠图图片失败:invalid png".to_string(), + Some("invalid png".to_string()), + &ExternalApiAuditContext::default(), + ); + + assert_eq!(draft.status_code, None); + assert_eq!(draft.status_class, Some("local")); + assert!(!draft.timeout); + assert!(!draft.retryable); + } + #[test] fn matting_failure_draft_keeps_client_error_non_retryable() { // 真实上游 4xx(非 429 / 408)不是传输故障,仍应 retryable=false,不能被误判为可重试。 @@ -633,6 +679,7 @@ mod tests { "aliyun_segment", Some(400), false, + false, Some(12), "通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image".to_string(), None,