diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 23ed2852f..76baac017 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,13 @@ ``` --- +## 2026-07-18 阿里云 URL 抠图链路按外部调用阶段审计 + +- 背景:阿里云 URL 抠图先从源 OSS GET,再解码、校验尺寸、归一化并上传临时 OSS;此前解码和尺寸失败仍使用普通 `InvalidRequest`,被错误标记为 `externalCallAttempted=false`,无法满足阿里云失败统一审计约定。 +- 决策:真正开始外部调用前的本地预检不写 `external_api_call_failure`;源 OSS GET 成功后发生的解码、尺寸、归一化、临时上传、阿里云请求和结果处理失败均进入审计。`platform-matting` 使用结构化 `LocalProcessing` 分类和 `failureStage`,由 api-server 映射为 `source_decode`、`source_validate`、`source_normalize`、`temp_upload`、`result_decode` 等阶段,不再把这些错误统称为“发请求前本地预检”。 +- 影响范围:`server-rs/crates/platform-matting/src/lib.rs`、`server-rs/crates/api-server/src/aliyun_matting.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/editor_project.rs`、后端架构文档。 +- 验证方式:运行 `cargo test -p platform-matting --manifest-path server-rs/Cargo.toml`、阿里云抠图与外部审计定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 + ## 2026-07-18 手动去背景稳定媒体引用校验收口 - 背景:当前分支与 `master` 分别增加手动去背景专用 Data URL 校验和编辑器通用稳定媒体引用校验,直接叠加会让 API 与 worker 重复执行语义相同的 helper,并造成 `data:` / `blob:` 覆盖范围和错误文案漂移。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 035315f16..61815353a 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -246,7 +246,7 @@ npm run check:server-rs-ddd - 图片生成: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` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 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 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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` 审计。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 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 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `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 调用失败和阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。 - BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新签发 600 秒 OSS URL 并获得同样的 request deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图 owned 上传 OSS 并释放原帧 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射,不在 api-server 增加供应商进程锁或固定小并发窗口;返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 - 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。 diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index c506e0cb4..8ae6c96be 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -47,18 +47,20 @@ pub(crate) async fn segment_image_url_with_aliyun_matting( /// /// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处 /// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在 -/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是发请求前的本地预检失败, -/// `external_call_attempted()` 为 false,据此跳过外部失败审计。 +/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是尚未开始外部调用的本地预检失败; +/// URL 链路在 OSS GET 成功后发生的解码、尺寸或其它本地处理失败由 `LocalProcessing` 表示, +/// 仍然需要进入外部失败审计。 fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError { let message = error.message(); if !error.external_call_attempted() { - // 本地预检失败(未配置 / 图片解码失败 / 尺寸过小 / 签名构造失败),未触达阿里云。 + // 本地预检失败(未配置 / 尚未下载源图前的参数或签名错误),未触达外部调用链路。 return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "aliyun-matting", "message": message, "timeout": false, "transport": false, "externalCallAttempted": false, + "failureStage": error.failure_stage(), "latencyMs": latency_ms, "rawExcerpt": message.chars().take(500).collect::(), })); @@ -75,6 +77,8 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> "timeout": timeout, "transport": error.is_transport(), "upstreamStatus": error.upstream_status(), + "externalCallAttempted": true, + "failureStage": error.failure_stage(), "latencyMs": latency_ms, "rawExcerpt": message.chars().take(500).collect::(), })) @@ -85,6 +89,7 @@ fn aliyun_matting_unconfigured_error() -> AppError { "provider": "aliyun-matting", "message": "阿里云抠图客户端未配置或未启用。", "externalCallAttempted": false, + "failureStage": "preflight", })) } @@ -143,6 +148,45 @@ mod tests { } } + #[test] + fn local_processing_after_source_download_is_audited_with_failure_stage() { + for (error, expected_stage) in [ + ( + MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()) + .with_failure_stage("source_decode"), + "source_decode", + ), + ( + MattingError::InvalidRequest("待抠图图片尺寸 16x16 过小".to_string()) + .with_failure_stage("source_validate"), + "source_validate", + ), + ] { + 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); + let details = mapped.details().expect("details present"); + assert_eq!( + details + .get("externalCallAttempted") + .and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + details.get("failureStage").and_then(|v| v.as_str()), + Some(expected_stage) + ); + assert_eq!( + crate::external_api_audit::matting_failure_audit_failure_stage( + &mapped, + "aliyun_segment", + ), + expected_stage + ); + } + } + #[test] fn upstream_transport_failure_maps_to_retryable_transport() { let error = MattingError::upstream_transport_error( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 19d22bdcf..4a5c07bfc 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3294,7 +3294,10 @@ async fn fallback_editor_screen_background_removal( "aliyun-matting", state.config.aliyun_matting_endpoint.clone(), "editor-screen-background-removal", - "aliyun_segment", + crate::external_api_audit::matting_failure_audit_failure_stage( + &error, + "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), 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 e62d3a0fb..691952e73 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -256,6 +256,30 @@ pub(crate) fn matting_failure_external_call_attempted(error: &AppError) -> bool .unwrap_or(true) } +pub(crate) fn matting_failure_audit_failure_stage( + error: &AppError, + fallback: &'static str, +) -> &'static str { + let stage = error + .details() + .and_then(|details| details.get("failureStage")) + .and_then(Value::as_str); + match stage { + Some("preflight") => "preflight", + Some("source_download") => "source_download", + Some("source_decode") => "source_decode", + Some("source_validate") => "source_validate", + Some("source_normalize") => "source_normalize", + Some("temp_upload") => "temp_upload", + Some("aliyun_segment") => "aliyun_segment", + Some("result_download") => "result_download", + Some("result_decode") => "result_decode", + Some("result_validate") => "result_validate", + Some("result_encode") => "result_encode", + _ => fallback, + } +} + pub(crate) fn matting_failure_audit_raw_excerpt(error: &AppError) -> Option { error .details() diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index 8e28404f3..e6107477d 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -140,10 +140,20 @@ pub struct SegmentCommonImageResult { pub enum MattingError { InvalidConfig(String), InvalidRequest(String), + LocalProcessing(LocalProcessingFailure), Sign(String), Upstream(UpstreamFailure), } +/// 外部调用已经开始,但本地处理阶段失败。 +/// +/// 这类错误仍然需要进入外部失败审计,但不能被误标成上游 HTTP / 传输故障。 +#[derive(Debug)] +pub struct LocalProcessingFailure { + message: String, + failure_stage: &'static str, +} + /// 结构化的上游调用失败分类。协议层归属(是否传输层故障、是否超时、上游 HTTP 状态)由 /// platform-matting 在错误发生处直接捕获,调用方(api-server BFF)不再从中文 message 反推。 #[derive(Debug)] @@ -155,6 +165,7 @@ pub struct UpstreamFailure { transport: bool, /// 上游返回的 HTTP 状态码;`None` 表示没拿到状态(传输层故障或响应体不可用)。 upstream_status: Option, + failure_stage: &'static str, } impl MattingError { @@ -163,14 +174,17 @@ impl MattingError { Self::InvalidConfig(message) | Self::InvalidRequest(message) | Self::Sign(message) => { message } + Self::LocalProcessing(failure) => &failure.message, Self::Upstream(failure) => &failure.message, } } - /// 是否真正向阿里云发起过外部调用。`InvalidConfig` / `InvalidRequest` / `Sign` 都是发请求前的 - /// 本地预检失败,未触达上游。 + /// 是否已经开始外部调用链路。 + /// + /// `LocalProcessing` 表示外部调用已经开始,但解码、尺寸校验或其它本地处理失败;它不能 + /// 与真正发请求前的 `InvalidConfig` / `InvalidRequest` / `Sign` 混为一谈。 pub fn external_call_attempted(&self) -> bool { - matches!(self, Self::Upstream(_)) + matches!(self, Self::LocalProcessing(_) | Self::Upstream(_)) } /// 上游调用是否为传输层超时。 @@ -191,6 +205,35 @@ impl MattingError { } } + /// 失败发生阶段,供 api-server 生成准确的外部失败审计 metadata。 + pub fn failure_stage(&self) -> &'static str { + match self { + Self::InvalidConfig(_) | Self::InvalidRequest(_) | Self::Sign(_) => "preflight", + Self::LocalProcessing(failure) => failure.failure_stage, + Self::Upstream(failure) => failure.failure_stage, + } + } + + /// 将一个已经发生外部调用后的本地错误转换为可审计的处理失败。 + pub fn with_failure_stage(self, failure_stage: &'static str) -> Self { + match self { + Self::InvalidConfig(message) | Self::InvalidRequest(message) | Self::Sign(message) => { + Self::LocalProcessing(LocalProcessingFailure { + message, + failure_stage, + }) + } + Self::LocalProcessing(mut failure) => { + failure.failure_stage = failure_stage; + Self::LocalProcessing(failure) + } + Self::Upstream(mut failure) => { + failure.failure_stage = failure_stage; + Self::Upstream(failure) + } + } + } + /// 传输层故障构造器:请求已发出但没拿到有效 HTTP 响应(连接、读体、超时)。 pub fn upstream_transport_error(message: String, timeout: bool) -> Self { Self::Upstream(UpstreamFailure { @@ -198,6 +241,7 @@ impl MattingError { timeout, transport: true, upstream_status: None, + failure_stage: "aliyun_segment", }) } @@ -208,6 +252,7 @@ impl MattingError { timeout: false, transport: false, upstream_status: Some(status), + failure_stage: "aliyun_segment", }) } @@ -219,6 +264,7 @@ impl MattingError { timeout: false, transport: false, upstream_status, + failure_stage: "aliyun_segment", }) } } @@ -398,37 +444,52 @@ impl MattingClient { } let (source_dims, upload_dims, image_url) = { - let source_bytes = self.download_source_image(source_url).await?; - let source = decode_source_image(&source_bytes)?; + let source_bytes = self + .download_source_image(source_url) + .await + .map_err(|error| error.with_failure_stage("source_download"))?; + let source = decode_source_image(&source_bytes) + .map_err(|error| error.with_failure_stage("source_decode"))?; let source_dims = (source.width(), source.height()); - validate_source_dimensions(source_dims)?; - let (upload_bytes, upload_dims) = normalize_matting_input_png(&source)?; + validate_source_dimensions(source_dims) + .map_err(|error| error.with_failure_stage("source_validate"))?; + let (upload_bytes, upload_dims) = normalize_matting_input_png(&source) + .map_err(|error| error.with_failure_stage("source_normalize"))?; let image_url = self .upload_temp_image(upload_bytes, file_name, "image/png") - .await?; + .await + .map_err(|error| error.with_failure_stage("temp_upload"))?; (source_dims, upload_dims, image_url) }; let result_image = self .segment_uploaded_input_to_rgba(image_url, source_dims, upload_dims) - .await?; + .await + .map_err(|error| error.with_failure_stage("aliyun_segment"))?; if upload_dims == source_dims { - return encode_rgba_png(&result_image); + return encode_rgba_png(&result_image) + .map_err(|error| error.with_failure_stage("result_encode")); } // 只有降尺寸送抠时才重新下载源图恢复原始 RGB;第一次下载缓冲已在上传临时对象后释放。 let source_rgba = { - let source_bytes = self.download_source_image(source_url).await?; - let source = decode_source_image(&source_bytes)?; + let source_bytes = self + .download_source_image(source_url) + .await + .map_err(|error| error.with_failure_stage("source_download"))?; + let source = decode_source_image(&source_bytes) + .map_err(|error| error.with_failure_stage("source_decode"))?; if (source.width(), source.height()) != source_dims { return Err(MattingError::upstream_response_error( "待抠图源图在处理期间尺寸发生变化".to_string(), None, - )); + ) + .with_failure_stage("source_validate")); } source.to_rgba8() }; compose_source_with_result_alpha(source_rgba, result_image) + .map_err(|error| error.with_failure_stage("result_encode")) } async fn segment_uploaded_input_to_rgba( @@ -443,10 +504,16 @@ impl MattingClient { // 默认 ReturnForm:原尺寸 + 透明背景,无需本地合成。 return_form: None, }) - .await?; + .await + .map_err(|error| error.with_failure_stage("aliyun_segment"))?; let result_image = { - let result_bytes = self.download_result_image(&result.image_url).await?; - decode_result_image(&result_bytes)?.to_rgba8() + let result_bytes = self + .download_result_image(&result.image_url) + .await + .map_err(|error| error.with_failure_stage("result_download"))?; + decode_result_image(&result_bytes) + .map_err(|error| error.with_failure_stage("result_decode"))? + .to_rgba8() }; if upload_dims == source_dims && result_image.dimensions() != source_dims { return Err(MattingError::upstream_response_error( @@ -458,7 +525,8 @@ impl MattingClient { source_dims.1 ), None, - )); + ) + .with_failure_stage("result_validate")); } Ok(result_image) } @@ -1099,6 +1167,18 @@ mod tests { } } + #[test] + fn local_processing_after_external_call_is_auditable_with_stage() { + let error = MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()) + .with_failure_stage("source_decode"); + + assert!(error.external_call_attempted()); + assert_eq!(error.failure_stage(), "source_decode"); + assert!(!error.is_timeout()); + assert!(!error.is_transport()); + assert_eq!(error.upstream_status(), None); + } + #[test] fn upstream_transport_error_classifies_as_transport() { let error = MattingError::upstream_transport_error( @@ -1425,7 +1505,7 @@ mod tests { let body = &tail[..end]; let download = body - .find("let source_bytes = self.download_source_image(source_url).await?") + .find("let source_bytes = self\n .download_source_image(source_url)") .expect("source should download lazily"); let upload = body .find(".upload_temp_image(upload_bytes, file_name, \"image/png\")")