修复抠图失败审计元数据
区分抠图传输失败与上游 HTTP 状态 补齐 BgFilter 与阿里云抠图失败 latencyMs 同步阿里云抠图配置与角色动作背景色文档口径
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递
|
||||
|
||||
- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。
|
||||
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色` 或 `抠图模型`。api-server 在 12 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`。后端仍保留手动 hex 解析能力供内部兼容,角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。
|
||||
- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色` 或 `抠图模型`。api-server 在 12 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`。后端仍保留手动 hex 解析能力供内部兼容。角色动作背景色和抠帧口径已由 2026-07-09 决策取代。
|
||||
- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、BgFilter 服务入参、图片画布 MVP 和角色形象生成设计文档。
|
||||
- 验证方式:运行画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_green_screen --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image generated_asset_sheet_light_blue_key_color_removes_selected_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
@@ -51,7 +51,7 @@
|
||||
## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter
|
||||
|
||||
- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color` 和 `seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。
|
||||
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=<screenColor>` 和 `seg_model=<segModel>`,前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时 `45000ms`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。
|
||||
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=<screenColor>` 和 `seg_model=<segModel>`,前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时 `180000ms`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作背景色和抠帧口径已由 2026-07-09 决策取代:角色动作同样使用多色自动决策,抽帧后优先阿里云通用抠图,失败再按选定背景色本地兜底。
|
||||
- 影响范围:`server-rs/crates/api-server/src/config.rs`、`server-rs/crates/api-server/src/editor_project.rs`、图片画布 MVP 文档和角色形象生成设计文档。
|
||||
- 验证方式:运行 `cargo test -p api-server config::tests::from_env_reads_editor_bgfilter_settings_and_reuses_background_token editor_project::tests::editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
@@ -220,7 +220,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
|
||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。
|
||||
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>` 和 `seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,会在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接使用本地 `editor_green_screen` 兜底,避免上游故障占住 worker。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`);BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。
|
||||
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>` 和 `seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,会在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接使用本地 `editor_green_screen` 兜底,避免上游故障占住 worker。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。BgFilter 与阿里云抠图失败都写入 `external_api_call_failure` 审计。
|
||||
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
||||
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
||||
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) async fn segment_image_with_aliyun_matting(
|
||||
.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 {
|
||||
@@ -41,7 +42,9 @@ pub(crate) async fn segment_image_with_aliyun_matting(
|
||||
"provider": "aliyun-matting",
|
||||
"message": message,
|
||||
"timeout": timeout,
|
||||
"upstreamStatus": aliyun_matting_error_http_status(message),
|
||||
"transport": aliyun_matting_error_is_transport(message),
|
||||
"upstreamStatus": upstream_status,
|
||||
"latencyMs": started_at.elapsed().as_millis() as u64,
|
||||
"rawExcerpt": message.chars().take(500).collect::<String>(),
|
||||
}))
|
||||
})?;
|
||||
@@ -74,6 +77,10 @@ fn aliyun_matting_error_http_status(message: &str) -> Option<u16> {
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn aliyun_matting_error_is_transport(message: &str) -> bool {
|
||||
aliyun_matting_error_http_status(message).is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -105,4 +112,14 @@ mod tests {
|
||||
"通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliyun_matting_error_transport_requires_missing_http_status() {
|
||||
assert!(aliyun_matting_error_is_transport(
|
||||
"通用抠图请求失败:dns error"
|
||||
));
|
||||
assert!(!aliyun_matting_error_is_transport(
|
||||
"通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2247,6 +2247,7 @@ async fn remove_editor_character_animation_frame_backgrounds(
|
||||
"aliyun_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_latency_ms(&error),
|
||||
error.message().to_string(),
|
||||
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error)
|
||||
.or_else(|| Some(format!("frame_index={frame_index}"))),
|
||||
|
||||
@@ -2268,6 +2268,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
|
||||
"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_latency_ms(&error),
|
||||
error.message().to_string(),
|
||||
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
|
||||
)
|
||||
@@ -2297,6 +2298,7 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
|
||||
"aliyun_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_latency_ms(&error),
|
||||
error.message().to_string(),
|
||||
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
|
||||
)
|
||||
@@ -2419,14 +2421,15 @@ async fn request_editor_generated_screen_background_with_bgfilter(
|
||||
request = request.header("X-Genarrative-Image-Token", token);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let latency_ms = request_started_at.elapsed().as_millis() as u64;
|
||||
tracing::warn!(
|
||||
%call_id,
|
||||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||
elapsed_ms = latency_ms,
|
||||
timeout = error.is_timeout(),
|
||||
error = %error,
|
||||
"editor_bgfilter_request_failed"
|
||||
);
|
||||
map_editor_bgfilter_error(error)
|
||||
map_editor_bgfilter_error(error, latency_ms)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
@@ -2446,6 +2449,7 @@ async fn request_editor_generated_screen_background_with_bgfilter(
|
||||
"provider": "bgfilter",
|
||||
"message": "BgFilter 服务返回非成功状态",
|
||||
"upstreamStatus": status.as_u16(),
|
||||
"latencyMs": request_started_at.elapsed().as_millis() as u64,
|
||||
"upstreamMessage": message.chars().take(500).collect::<String>(),
|
||||
})),
|
||||
);
|
||||
@@ -2702,7 +2706,7 @@ fn map_editor_background_removal_error(error: reqwest::Error) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_editor_bgfilter_error(error: reqwest::Error) -> AppError {
|
||||
fn map_editor_bgfilter_error(error: reqwest::Error, latency_ms: u64) -> AppError {
|
||||
let status = if error.is_timeout() {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
@@ -2712,6 +2716,8 @@ fn map_editor_bgfilter_error(error: reqwest::Error) -> AppError {
|
||||
"provider": "bgfilter",
|
||||
"message": format!("请求 BgFilter 服务失败:{error}"),
|
||||
"timeout": error.is_timeout(),
|
||||
"transport": true,
|
||||
"latencyMs": latency_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(crate) async fn record_matting_external_api_failure(
|
||||
failure_stage: &'static str,
|
||||
status_code: Option<u16>,
|
||||
timeout: bool,
|
||||
latency_ms: Option<u64>,
|
||||
error_message: String,
|
||||
raw_excerpt: Option<String>,
|
||||
) {
|
||||
@@ -171,6 +172,7 @@ pub(crate) async fn record_matting_external_api_failure(
|
||||
timeout,
|
||||
false,
|
||||
))
|
||||
.with_latency_ms(latency_ms)
|
||||
.with_raw_excerpt(raw_excerpt)
|
||||
.with_audit_context(context);
|
||||
record_external_api_failure(state, draft).await;
|
||||
@@ -183,7 +185,7 @@ pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option<u16>
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.or_else(|| {
|
||||
if matting_failure_audit_timeout(error) {
|
||||
if matting_failure_audit_is_transport(error) {
|
||||
None
|
||||
} else {
|
||||
Some(error.status_code().as_u16())
|
||||
@@ -191,6 +193,15 @@ pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option<u16>
|
||||
})
|
||||
}
|
||||
|
||||
fn matting_failure_audit_is_transport(error: &AppError) -> bool {
|
||||
matting_failure_audit_timeout(error)
|
||||
|| error
|
||||
.details()
|
||||
.and_then(|details| details.get("transport"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_timeout(error: &AppError) -> bool {
|
||||
error
|
||||
.details()
|
||||
@@ -199,6 +210,13 @@ pub(crate) fn matting_failure_audit_timeout(error: &AppError) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_latency_ms(error: &AppError) -> Option<u64> {
|
||||
error
|
||||
.details()
|
||||
.and_then(|details| details.get("latencyMs"))
|
||||
.and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
pub(crate) fn matting_failure_audit_raw_excerpt(error: &AppError) -> Option<String> {
|
||||
error
|
||||
.details()
|
||||
@@ -541,6 +559,7 @@ mod tests {
|
||||
"message": "BgFilter 服务返回非成功状态",
|
||||
"upstreamStatus": 429,
|
||||
"upstreamMessage": "too many requests",
|
||||
"latencyMs": 345,
|
||||
}));
|
||||
|
||||
let status_code = matting_failure_audit_status_code(&error);
|
||||
@@ -556,6 +575,7 @@ mod tests {
|
||||
.with_status_code(status_code)
|
||||
.with_optional_status_class(Some(status_class(status_code)))
|
||||
.with_timeout(timeout)
|
||||
.with_latency_ms(matting_failure_audit_latency_ms(&error))
|
||||
.with_retryable(is_retryable_external_api_failure(
|
||||
status_code,
|
||||
timeout,
|
||||
@@ -568,6 +588,7 @@ mod tests {
|
||||
assert_eq!(tracking.metadata["statusClass"], "4xx");
|
||||
assert_eq!(tracking.metadata["timeout"], false);
|
||||
assert_eq!(tracking.metadata["retryable"], true);
|
||||
assert_eq!(tracking.metadata["latencyMs"], 345);
|
||||
assert_eq!(tracking.metadata["rawExcerpt"], "too many requests");
|
||||
}
|
||||
|
||||
@@ -604,4 +625,42 @@ mod tests {
|
||||
assert_eq!(tracking.metadata["timeout"], true);
|
||||
assert_eq!(tracking.metadata["retryable"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matting_failure_audit_keeps_non_timeout_transport_classification() {
|
||||
let error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "bgfilter",
|
||||
"message": "请求 BgFilter 服务失败:dns error",
|
||||
"timeout": false,
|
||||
"transport": true,
|
||||
"latencyMs": 67,
|
||||
}));
|
||||
|
||||
let status_code = matting_failure_audit_status_code(&error);
|
||||
let timeout = matting_failure_audit_timeout(&error);
|
||||
let tracking = build_external_api_failure_tracking_draft(
|
||||
&ExternalApiFailureDraft::new(
|
||||
"bgfilter",
|
||||
"https://bgfilter.example/remove-background",
|
||||
"editor-screen-background-removal",
|
||||
"bgfilter_segment",
|
||||
error.message(),
|
||||
)
|
||||
.with_status_code(status_code)
|
||||
.with_optional_status_class(Some(status_class(status_code)))
|
||||
.with_timeout(timeout)
|
||||
.with_latency_ms(matting_failure_audit_latency_ms(&error))
|
||||
.with_retryable(is_retryable_external_api_failure(
|
||||
status_code,
|
||||
timeout,
|
||||
false,
|
||||
)),
|
||||
);
|
||||
|
||||
assert_eq!(tracking.metadata["statusCode"], Value::Null);
|
||||
assert_eq!(tracking.metadata["statusClass"], "transport");
|
||||
assert_eq!(tracking.metadata["timeout"], false);
|
||||
assert_eq!(tracking.metadata["retryable"], false);
|
||||
assert_eq!(tracking.metadata["latencyMs"], 67);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user