BgFilter/阿里云兜底抠图失败接入外部 API 失败审计

架构要求外部供应商调用失败必须进 OTLP + tracking_event
external_api_call_failure,但 BgFilter 和阿里云通用抠图失败原来只
warn! 后静默兜底,生产故障在审计链路里不可见(P1)。

- external_api_audit:新增 ExternalApiAuditContext 与统一入口
  record_matting_external_api_failure(复用 record_external_api_failure,
  自动填 status_class/retryable);is_retryable_external_api_failure 去掉
  cfg(test) 门供生产复用,内部 StatusCode 换字面量避开测试专用导入。
- 三处失败点接入:BgFilter 请求失败、生图链路阿里云兜底失败、
  动作视频逐帧阿里云兜底失败(带 frame_index)。
- user/profile/request_id 从各入口 caller / owner+project+request_context
  透传进兜底函数,沿用现有 external_api_audit 上下文映射。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 03:38:00 +00:00
parent ef8ac94395
commit 31117ffdb9
3 changed files with 123 additions and 6 deletions
@@ -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<Vec<EditorCharacterAnimationFramePayload>, 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<Vec<FinalizedAnimationFrame>, 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)?
}
};
@@ -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<DownloadedOpenAiImage, AppError> {
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())
@@ -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<String>,
pub(crate) profile_id: Option<String>,
pub(crate) request_id: Option<String>,
}
/// 抠图供应商(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<String>,
) {
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<u16>,
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) {