修复阿里云 URL 抠图失败审计分类
新增外部调用后本地处理失败分类和失败阶段字段 将 OSS GET 后解码、尺寸、归一化和结果处理失败纳入审计 更新 api-server 审计桥接、后端契约文档和共享决策记录 补充阿里云抠图与外部审计定向测试
This commit is contained in:
@@ -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:` 覆盖范围和错误文案漂移。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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::<String>(),
|
||||
}));
|
||||
@@ -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::<String>(),
|
||||
}))
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String> {
|
||||
error
|
||||
.details()
|
||||
|
||||
@@ -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<u16>,
|
||||
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\")")
|
||||
|
||||
Reference in New Issue
Block a user