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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user