diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index dd7899953..1cf3bdb5d 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -686,6 +686,12 @@ pub(crate) async fn generate_editor_character_animation_for_owner( let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) .map_err(|error| character_animation_error_response(&request_context, error))?; let task_id = generate_ai_task_id(current_utc_micros()); + // 抠图供应商失败审计上下文:逐帧阿里云抠图失败即使兜底成功,也要进 OTLP + tracking_event。 + let matting_audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: Some(owner_user_id.clone()), + profile_id: project_id.clone(), + request_id: Some(request_context.request_id().to_string()), + }; let result = execute_billable_asset_operation_with_cost( &state, @@ -713,6 +719,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( generated.preview_video_path.as_str(), &normalized, &extraction_settings, + &matting_audit, ) .await?; @@ -2090,6 +2097,7 @@ async fn extract_and_persist_editor_character_animation_frames( preview_video_path: &str, request: &NormalizedEditorCharacterAnimationRequest, extraction_settings: &BackendFrameExtractionSettings, + audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result, AppError> { let plan = AnimationFrameExtractionPlan { frame_count: request.frame_count, @@ -2121,6 +2129,7 @@ async fn extract_and_persist_editor_character_animation_frames( request.frame_width, request.frame_height, request.screen_color, + audit, ) .await?; @@ -2199,6 +2208,7 @@ async fn remove_editor_character_animation_frame_backgrounds( frame_width: u32, frame_height: u32, screen_color: EditorScreenBackgroundColor, + audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result, AppError> { use futures_util::{StreamExt as _, TryStreamExt as _}; @@ -2228,6 +2238,18 @@ async fn remove_editor_character_animation_frame_backgrounds( error_details = ?error.details(), "editor_animation_frame_aliyun_matting_fallback_to_local" ); + crate::external_api_audit::record_matting_external_api_failure( + state, + audit, + "aliyun-matting", + state.config.aliyun_matting_endpoint.clone(), + "editor-character-animation-frame-matting", + "aliyun_segment", + error.status_code().as_u16(), + error.message().to_string(), + Some(format!("frame_index={frame_index}")), + ) + .await; remove_editor_generated_green_screen_background(&image, screen_color)? } }; diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index c8e207289..eea999ee6 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1554,11 +1554,20 @@ pub(crate) async fn generate_editor_image_for_owner( "character-image", ) .await?; + let matting_audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: caller.audit_subject_user_id.clone(), + profile_id: caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), + request_id: Some(request_context.request_id().to_string()), + }; image = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), + &matting_audit, ) .await?; } @@ -2226,6 +2235,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter( image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, + audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) { tracing::warn!( @@ -2260,6 +2270,18 @@ async fn remove_editor_generated_screen_background_with_bgfilter( error_details = ?error.details(), "editor_bgfilter_fallback_to_aliyun_matting" ); + crate::external_api_audit::record_matting_external_api_failure( + state, + audit, + "bgfilter", + state.config.editor_bgfilter_base_url.clone(), + "editor-screen-background-removal", + "bgfilter_segment", + error.status_code().as_u16(), + error.message().to_string(), + None, + ) + .await; match crate::aliyun_matting::segment_image_with_aliyun_matting( state, image, @@ -2276,6 +2298,18 @@ async fn remove_editor_generated_screen_background_with_bgfilter( error_details = ?error.details(), "editor_aliyun_matting_fallback_to_local_screen_background_removal" ); + crate::external_api_audit::record_matting_external_api_failure( + state, + audit, + "aliyun-matting", + state.config.aliyun_matting_endpoint.clone(), + "editor-screen-background-removal", + "aliyun_segment", + error.status_code().as_u16(), + error.message().to_string(), + None, + ) + .await; remove_editor_generated_green_screen_background(image, screen_color) } } @@ -2936,11 +2970,20 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheet", ) .await?; + let matting_audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: caller.audit_subject_user_id.clone(), + profile_id: caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), + request_id: Some(request_context.request_id().to_string()), + }; let image = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color, seg_model, + &matting_audit, ) .await?; let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) @@ -3204,11 +3247,20 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "spritesheet", ) .await?; + let matting_audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: caller.audit_subject_user_id.clone(), + profile_id: caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), + request_id: Some(request_context.request_id().to_string()), + }; let image = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color, seg_model, + &matting_audit, ) .await?; let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) 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 11a104d57..cc0dc824b 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -130,6 +130,53 @@ impl ExternalApiFailureDraft { self.request_id = request_id; self } + + pub(crate) fn with_audit_context(mut self, context: &ExternalApiAuditContext) -> Self { + self.user_id = context.user_id.clone(); + self.profile_id = context.profile_id.clone(); + self.request_id = context.request_id.clone(); + self + } +} + +/// 外部 API 失败审计的调用方上下文(用户 / 档案 / 请求 id)。 +#[derive(Clone, Debug, Default)] +pub(crate) struct ExternalApiAuditContext { + pub(crate) user_id: Option, + pub(crate) profile_id: Option, + pub(crate) request_id: Option, +} + +/// 抠图供应商(BgFilter / 阿里云通用抠图)调用失败的统一失败审计入口。 +/// 即使随后兜底成功,供应商故障也必须进入 OTLP + tracking_event,不能只 warn! 后静默。 +pub(crate) async fn record_matting_external_api_failure( + state: &AppState, + context: &ExternalApiAuditContext, + provider: &'static str, + endpoint: String, + operation: &'static str, + failure_stage: &'static str, + status_code: u16, + error_message: String, + raw_excerpt: Option, +) { + let draft = ExternalApiFailureDraft::new( + provider, + endpoint, + operation, + failure_stage, + error_message, + ) + .with_status_code(Some(status_code)) + .with_optional_status_class(Some(status_class(Some(status_code)))) + .with_retryable(is_retryable_external_api_failure( + Some(status_code), + false, + false, + )) + .with_raw_excerpt(raw_excerpt) + .with_audit_context(context); + record_external_api_failure(state, draft).await; } pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( @@ -306,19 +353,15 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val metadata } -#[cfg(test)] pub(crate) fn is_retryable_external_api_failure( status_code: Option, timeout: bool, connect: bool, ) -> bool { + // 429 Too Many Requests / 408 Request Timeout / 5xx 视为可重试。 timeout || connect - || status_code.is_some_and(|status| { - status == StatusCode::TOO_MANY_REQUESTS.as_u16() - || status == StatusCode::REQUEST_TIMEOUT.as_u16() - || status >= 500 - }) + || status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500) } fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {