Files
Genarrative/server-rs/crates/api-server/src/aliyun_matting.rs
T
lhk229 0c04bbbea3 内存优化 (#91)
BGfilter服务调用改为传url,减少内存占用

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/91
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-07-18 21:17:35 +08:00

298 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 阿里云通用抠图在 api-server 侧的适配层。
//!
//! 输入统一由 platform-matting 通过 AuthorizeFileUpload 单对象 Policy 上传动态临时 OSS。
use axum::http::StatusCode;
use platform_matting::MattingError;
use serde_json::{Value, json};
use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
};
/// 私有 OSS 签名 URL → 延迟下载 → 阿里云临时桶 → 原尺寸透明 PNG。
/// 下载和临时上传由 platform-matting 收口,源图缓冲不会跨越整次阿里云推理常驻。
pub(crate) async fn segment_image_url_with_aliyun_matting(
state: &AppState,
image_url: &str,
log_label: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let matting_client = state
.matting_client()
.ok_or_else(aliyun_matting_unconfigured_error)?;
let file_name = format!("{log_label}.png");
let started_at = std::time::Instant::now();
let output_bytes = matting_client
.segment_image_url_to_transparent_png(image_url, &file_name)
.await
.map_err(|error| {
aliyun_matting_failure_to_app_error(&error, started_at.elapsed().as_millis() as u64)
})?;
tracing::info!(
provider = "aliyun-matting",
log_label,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"阿里云通用抠图 URL 输入完成"
);
Ok(DownloadedOpenAiImage {
bytes: output_bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
/// 把 platform-matting 的错误映射成审计友好的 AppError。
///
/// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处
/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是尚未开始外部调用的本地预检失败;
/// URL 链路在 OSS GET 成功后发生的解码、尺寸或其它本地处理失败由 `LocalProcessing` 表示,
/// 仍然需要进入外部失败审计,但不得包装成可重试的上游 5xx。
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,
"localProcessing": false,
"externalCallAttempted": false,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
// 外部调用已开始,但失败在本地解码 / 校验 / 归一等阶段:要审计,不能标成上游 5xx / 可重试。
if matches!(error, MattingError::LocalProcessing(_)) {
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"localProcessing": true,
"upstreamStatus": Value::Null,
"externalCallAttempted": true,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
let timeout = error.is_timeout();
let status = if timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": timeout,
"transport": error.is_transport(),
"localProcessing": false,
"upstreamStatus": error.upstream_status(),
"externalCallAttempted": true,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
}
fn aliyun_matting_unconfigured_error() -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-matting",
"message": "阿里云抠图客户端未配置或未启用。",
"externalCallAttempted": false,
"failureStage": "preflight",
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliyun_matting_unconfigured_error_marks_no_external_call_attempt() {
let error = aliyun_matting_unconfigured_error();
assert!(!crate::external_api_audit::matting_failure_external_call_attempted(&error));
}
#[test]
fn url_input_path_delegates_download_and_temp_upload_to_platform_matting() {
let source = include_str!("aliyun_matting.rs");
let start = source
.find("pub(crate) async fn segment_image_url_with_aliyun_matting")
.expect("URL input adapter should exist");
let tail = &source[start..];
let end = tail
.find("/// 把 platform-matting 的错误映射")
.expect("URL input adapter should end before error mapper");
let body = &tail[..end];
assert!(body.contains("segment_image_url_to_transparent_png"));
}
#[test]
fn local_preflight_failures_do_not_count_as_external_call() {
for error in [
MattingError::InvalidRequest(
"待抠图图片尺寸 16x16 过小,阿里云通用抠图要求每条边大于 32 像素".to_string(),
),
MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()),
MattingError::InvalidConfig("endpoint 为空".to_string()),
MattingError::Sign("构造 AuthorizeFileUpload 签名失败".to_string()),
] {
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
assert!(
!crate::external_api_audit::matting_failure_external_call_attempted(&mapped),
"本地预检失败不应记为外部调用:{}",
error.message()
);
let details = mapped.details().expect("details present");
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
details.get("timeout").and_then(|v| v.as_bool()),
Some(false)
);
}
}
#[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));
// 本地处理失败要审计,但 HTTP 包装不得落成可重试 5xx。
assert_eq!(mapped.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
let details = mapped.details().expect("details present");
assert_eq!(
details
.get("externalCallAttempted")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("localProcessing").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(false)
);
assert!(
details
.get("upstreamStatus")
.is_none_or(|value| value.is_null())
);
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
);
assert_eq!(
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
None,
"本地处理失败没有上游 HTTP 状态,不能回退包装码"
);
let draft = crate::external_api_audit::build_matting_external_api_failure_draft(
"aliyun-matting",
"imageseg.example".to_string(),
"editor-screen-background-removal",
expected_stage,
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
crate::external_api_audit::matting_failure_audit_timeout(&mapped),
crate::external_api_audit::matting_failure_audit_is_transport(&mapped),
crate::external_api_audit::matting_failure_audit_latency_ms(&mapped),
mapped.message().to_string(),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&mapped),
&crate::external_api_audit::ExternalApiAuditContext::default(),
);
assert_eq!(draft.status_class, Some("local"));
assert!(!draft.retryable);
}
}
#[test]
fn upstream_transport_failure_maps_to_retryable_transport() {
let error = MattingError::upstream_transport_error(
"通用抠图请求失败:dns error".to_string(),
false,
);
let mapped = aliyun_matting_failure_to_app_error(&error, 12);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
let details = mapped.details().expect("details present");
// 无 HTTP 状态的传输层失败标记为可重试 transport 故障,且不带 upstreamStatus。
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("timeout").and_then(|v| v.as_bool()),
Some(false)
);
assert!(details.get("upstreamStatus").is_some_and(|v| v.is_null()));
}
#[test]
fn upstream_timeout_failure_maps_to_gateway_timeout() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:timed out".to_string(), true);
let mapped = aliyun_matting_failure_to_app_error(&error, 7);
assert_eq!(mapped.status_code(), StatusCode::GATEWAY_TIMEOUT);
let details = mapped.details().expect("details present");
assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn upstream_http_status_failure_carries_status_without_transport() {
let error = MattingError::upstream_http_error(
"通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded".to_string(),
429,
);
let mapped = aliyun_matting_failure_to_app_error(&error, 5);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
let details = mapped.details().expect("details present");
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
details.get("upstreamStatus").and_then(|v| v.as_u64()),
Some(429)
);
}
}