0c04bbbea3
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>
298 lines
12 KiB
Rust
298 lines
12 KiB
Rust
//! 阿里云通用抠图在 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)
|
||
);
|
||
}
|
||
}
|