消除像素规整降级路径的重复与无界 OSS 回读
Project CI / Repository checks (pull_request) Successful in 1m0s
Project CI / Frontend tests (pull_request) Successful in 2m1s
Project CI / Native shell tests (pull_request) Successful in 2m26s
Project CI / Backend tests (pull_request) Successful in 3m28s

尺寸守卫此前一律走会回读 provider 原图的包装函数,带来两个问题:permit
获取失败、worker 内 deadline、join 异常和 worker 超时这四条分支其实已经
持有原图,却又对同一 object key 发起第二次 GET,把"最多增加一次 OSS GET"
放大成两次;而第一次回读超时后的那次 GET 完全没有绝对 deadline,突破
30 秒像素预算。

拆成两类:已持有原图的四条分支改走纯内存守卫 degrade_editor_pixel_art_
with_provider_source,零额外 GET;尚未取得原图的三条分支仍走回读守卫,
但该次 GET 改以外层请求 deadline 为绝对上界——用已耗尽的像素预算绑会让
修复必然失败,不绑则突破预算。

apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original
新增可选 download_deadline,非像素路径传 None 保持既有语义;尺寸比对抽为
editor_postprocessed_alpha_matches_delivery_dimensions 供两类守卫共用。
计数断言固定"回读守卫 3 处、内存守卫 4 处",该断言在编写时即抓出一处
分支数误判。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:29:42 +00:00
parent 414735efde
commit 937378ab91
2 changed files with 173 additions and 65 deletions
@@ -23,6 +23,7 @@
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。
- 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check` - 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check`
- 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。 - 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。
- 补充(同日):守卫的回读必须分两类处理,否则会把「最多增加一次 OSS GET」放大成两次、且第二次无界。已取得 provider 原图的四条降级分支(permit 获取失败、worker 内 deadline、join 异常、worker 超时)改走纯内存守卫 `degrade_editor_pixel_art_with_provider_source`,零额外 GET;尚未取得原图的三条分支(进函数即预算耗尽、第一次回读失败、第一次回读超时)才走会回读的守卫,且该次 GET 以**外层请求 deadline**(而非已耗尽的像素预算)为绝对上界——用像素预算绑会让修复必然失败,不绑则突破预算。`apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original` 因此新增可选 `download_deadline`,非像素路径传 `None` 保持既有语义不变。计数断言固定「回读守卫 3 处、内存守卫 4 处」,防止后续新增分支时误用回读版本。
--- ---
+172 -65
View File
@@ -2042,6 +2042,7 @@ pub(crate) async fn generate_editor_image_for_owner(
delivery_width, delivery_width,
delivery_height, delivery_height,
removal.image, removal.image,
None,
) )
.await; .await;
(image, error, None) (image, error, None)
@@ -3082,14 +3083,24 @@ fn apply_editor_postprocessed_alpha_to_provider_source_with_policy(
})) }))
} }
async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( // 中文注释:已经持有 provider 原图时的纯内存尺寸守卫,不发起任何 OSS 请求。
state: &AppState, fn apply_editor_postprocessed_alpha_with_provider_source_or_original(
provider_source_object_key: &str, provider_source: &DownloadedOpenAiImage,
provider_width: u32,
provider_height: u32,
postprocessed: DownloadedOpenAiImage, postprocessed: DownloadedOpenAiImage,
) -> (DownloadedOpenAiImage, Option<AppError>) { ) -> (DownloadedOpenAiImage, Option<AppError>) {
let postprocessed_dimensions = match apply_editor_postprocessed_alpha_to_provider_source(provider_source, &postprocessed) {
Ok(Some(restored)) => (restored, None),
Ok(None) => (postprocessed, None),
Err(error) => (postprocessed, Some(error)),
}
}
fn editor_postprocessed_alpha_matches_delivery_dimensions(
postprocessed: &DownloadedOpenAiImage,
provider_width: u32,
provider_height: u32,
) -> Result<bool, AppError> {
let (postprocessed_width, postprocessed_height) =
image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice())) image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice()))
.with_guessed_format() .with_guessed_format()
.map_err(|error| { .map_err(|error| {
@@ -3105,28 +3116,55 @@ async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_orig
"message": format!("无法读取透明后处理图尺寸:{error}"), "message": format!("无法读取透明后处理图尺寸:{error}"),
})) }))
}) })
}); })?;
match postprocessed_dimensions { Ok(postprocessed_width == provider_width && postprocessed_height == provider_height)
Ok((postprocessed_width, postprocessed_height)) }
if postprocessed_width == provider_width && postprocessed_height == provider_height =>
{ async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
return (postprocessed, None); state: &AppState,
} provider_source_object_key: &str,
Ok(_) => {} provider_width: u32,
provider_height: u32,
postprocessed: DownloadedOpenAiImage,
download_deadline: Option<Instant>,
) -> (DownloadedOpenAiImage, Option<AppError>) {
match editor_postprocessed_alpha_matches_delivery_dimensions(
&postprocessed,
provider_width,
provider_height,
) {
Ok(true) => return (postprocessed, None),
Ok(false) => {}
Err(error) => return (postprocessed, Some(error)), Err(error) => return (postprocessed, Some(error)),
} }
// 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。 // 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。
let provider_source = // download_deadline 为像素规整降级路径提供绝对上界,避免在预算已耗尽后仍发起无界 GET;
match download_editor_persisted_image_object(state, provider_source_object_key).await { // 非像素路径沿用既有语义传 None。
Ok(provider_source) => provider_source, let download = download_editor_persisted_image_object(state, provider_source_object_key);
Err(error) => return (postprocessed, Some(error)), let downloaded = match download_deadline {
}; Some(deadline) => {
match apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) { match tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), download).await
Ok(Some(restored)) => (restored, None), {
Ok(None) => (postprocessed, None), Ok(downloaded) => downloaded,
Err(error) => (postprocessed, Some(error)), Err(_) => Err(
} AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({
"provider": "editor-image-postprocess",
"message": "回读 provider 原图超时,已保留透明后处理图。",
})),
),
}
}
None => download.await,
};
let provider_source = match downloaded {
Ok(provider_source) => provider_source,
Err(error) => return (postprocessed, Some(error)),
};
apply_editor_postprocessed_alpha_with_provider_source_or_original(
&provider_source,
postprocessed,
)
} }
fn take_arc_downloaded_image(image: Arc<DownloadedOpenAiImage>) -> DownloadedOpenAiImage { fn take_arc_downloaded_image(image: Arc<DownloadedOpenAiImage>) -> DownloadedOpenAiImage {
@@ -3221,8 +3259,12 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
delivery_width: u32, delivery_width: u32,
delivery_height: u32, delivery_height: u32,
postprocessed: DownloadedOpenAiImage, postprocessed: DownloadedOpenAiImage,
request_deadline: Option<Instant>,
reason: String, reason: String,
) -> (DownloadedOpenAiImage, Option<AppError>, Option<String>) { ) -> (DownloadedOpenAiImage, Option<AppError>, Option<String>) {
// 中文注释:只有尚未成功取得 provider 原图的降级分支才会走到这里。像素预算此时
// 多半已经耗尽,不能拿它去绑这次回读(否则必然失败、守卫形同虚设),改用外层
// 请求 deadline 作为绝对上界,保证这唯一一次 GET 有界。
let (image, dimension_error) = let (image, dimension_error) =
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state, state,
@@ -3230,11 +3272,39 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
delivery_width, delivery_width,
delivery_height, delivery_height,
postprocessed, postprocessed,
request_deadline,
) )
.await; .await;
(image, dimension_error, Some(reason)) (image, dimension_error, Some(reason))
} }
// 中文注释:provider 原图已在内存里的降级分支走这里,零额外 OSS GET,
// 保证「最多因复用失败增加一次 GET」的不变式不被降级路径打破。
fn degrade_editor_pixel_art_with_provider_source(
provider_source: &DownloadedOpenAiImage,
delivery_width: u32,
delivery_height: u32,
postprocessed: DownloadedOpenAiImage,
reason: String,
) -> (DownloadedOpenAiImage, Option<AppError>, Option<String>) {
match editor_postprocessed_alpha_matches_delivery_dimensions(
&postprocessed,
delivery_width,
delivery_height,
) {
Ok(true) => (postprocessed, None, Some(reason)),
Ok(false) => {
let (image, dimension_error) =
apply_editor_postprocessed_alpha_with_provider_source_or_original(
provider_source,
postprocessed,
);
(image, dimension_error, Some(reason))
}
Err(error) => (postprocessed, Some(error), Some(reason)),
}
}
async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(
state: &AppState, state: &AppState,
provider_source_object_key: &str, provider_source_object_key: &str,
@@ -3255,6 +3325,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
delivery_width, delivery_width,
delivery_height, delivery_height,
postprocessed, postprocessed,
request_deadline,
"像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(),
) )
.await; .await;
@@ -3274,6 +3345,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
delivery_width, delivery_width,
delivery_height, delivery_height,
postprocessed, postprocessed,
request_deadline,
reason, reason,
) )
.await; .await;
@@ -3285,6 +3357,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
delivery_width, delivery_width,
delivery_height, delivery_height,
postprocessed, postprocessed,
request_deadline,
"像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(), "像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(),
) )
.await; .await;
@@ -3293,15 +3366,13 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await {
Ok(permit) => permit, Ok(permit) => permit,
Err(error) => { Err(error) => {
return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( return degrade_editor_pixel_art_with_provider_source(
state, &provider_source,
provider_source_object_key,
delivery_width, delivery_width,
delivery_height, delivery_height,
postprocessed, postprocessed,
error, error,
) );
.await;
} }
}; };
@@ -3356,39 +3427,27 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
Some(preparation_error), Some(preparation_error),
Some(error), Some(error),
), ),
Ok(Ok(Err((None, None, error)))) => { Ok(Ok(Err((None, None, error)))) => degrade_editor_pixel_art_with_provider_source(
degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( provider_source.as_ref(),
state, delivery_width,
provider_source_object_key, delivery_height,
delivery_width, take_arc_downloaded_image(postprocessed),
delivery_height, error,
take_arc_downloaded_image(postprocessed), ),
error, Ok(Err(error)) => degrade_editor_pixel_art_with_provider_source(
) provider_source.as_ref(),
.await delivery_width,
} delivery_height,
Ok(Err(error)) => { take_arc_downloaded_image(postprocessed),
degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( format!("像素规整工作线程异常:{error}"),
state, ),
provider_source_object_key, Err(_) => degrade_editor_pixel_art_with_provider_source(
delivery_width, provider_source.as_ref(),
delivery_height, delivery_width,
take_arc_downloaded_image(postprocessed), delivery_height,
format!("像素规整工作线程异常:{error}"), take_arc_downloaded_image(postprocessed),
) "像素规整处理超时,已保留透明后处理图。".to_string(),
.await ),
}
Err(_) => {
degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
take_arc_downloaded_image(postprocessed),
"像素规整处理超时,已保留透明后处理图。".to_string(),
)
.await
}
} }
} }
@@ -4731,6 +4790,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
source_width, source_width,
source_height, source_height,
removal.image, removal.image,
None,
) )
.await; .await;
(image, error, None) (image, error, None)
@@ -5506,6 +5566,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
source_width, source_width,
source_height, source_height,
removal.image, removal.image,
None,
) )
.await; .await;
if let Some(error) = postprocess_dimension_error { if let Some(error) = postprocess_dimension_error {
@@ -9328,6 +9389,45 @@ mod tests {
"fallback.unwrap_or_else(", "fallback.unwrap_or_else(",
], ],
); );
// 中文注释:像素函数里只有「尚未取得 provider 原图」的三条分支(进函数即预算耗尽、
// 第一次回读失败、第一次回读超时)允许走会回读的守卫;取得原图之后的四条分支
// permit 获取失败、worker 内 deadline、join 异常、worker 超时)必须走纯内存守卫,
// 否则单次请求会对同一 object key 发出两次 GET,突破「最多增加一次 GET」的不变式。
let pixel_fn_start = source
.find("async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(")
.expect("pixel art entry should exist");
let pixel_fn_body = &source[pixel_fn_start..];
let pixel_fn_body = &pixel_fn_body[..pixel_fn_body
.find("fn restore_editor_generated_image_output_dimensions_with_filter")
.expect("pixel art entry should be followed by the dimension restore helper")];
assert_eq!(
pixel_fn_body
.matches("degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(")
.count(),
3,
"只有未取得 provider 原图的三条分支可以走回读守卫"
);
assert_eq!(
pixel_fn_body
.matches("degrade_editor_pixel_art_with_provider_source(")
.count(),
4,
"取得 provider 原图后的四条降级分支必须走纯内存守卫"
);
// 纯内存守卫本身不得触碰 OSS。
assert_function_not_contains(
source,
"fn degrade_editor_pixel_art_with_provider_source",
"async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original",
&["download_editor_persisted_image_object"],
);
// 会回读原图的守卫必须带绝对 deadline 参数,不允许无界 GET。
assert_function_contains_in_order(
source,
"async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard",
"fn degrade_editor_pixel_art_with_provider_source",
&["request_deadline: Option<Instant>", "request_deadline,"],
);
// 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时 // 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时
// 不产生额外 OSS GET,漂移修不好时返回尺寸错误。 // 不产生额外 OSS GET,漂移修不好时返回尺寸错误。
assert_function_contains_in_order( assert_function_contains_in_order(
@@ -12326,17 +12426,24 @@ mod tests {
"async fn persist_editor_provider_source_resource", "async fn persist_editor_provider_source_resource",
&["persist_editor_generated_image_owned"], &["persist_editor_generated_image_owned"],
); );
// 中文注释:尺寸一致时必须在回读 provider 原图之前就返回,只有漂移才发起 GET。
assert_function_contains_in_order( assert_function_contains_in_order(
source, source,
"async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
"fn restore_editor_generated_image_output_dimensions", "fn restore_editor_generated_image_output_dimensions",
&[ &[
"into_dimensions", "editor_postprocessed_alpha_matches_delivery_dimensions",
"return (postprocessed, None)", "Ok(true) => return (postprocessed, None)",
"download_editor_persisted_image_object", "download_editor_persisted_image_object",
"apply_editor_postprocessed_alpha_to_provider_source", "apply_editor_postprocessed_alpha_with_provider_source_or_original",
], ],
); );
assert_function_contains_in_order(
source,
"fn editor_postprocessed_alpha_matches_delivery_dimensions",
"async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
&["into_dimensions"],
);
for (start, end) in [ for (start, end) in [
( (
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub(crate) async fn generate_editor_icon_spritesheet_for_owner",