diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c2b9f4ad4..938a77df2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - 影响范围:`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`。 - 关联文档:本文件「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 处」,防止后续新增分支时误用回读版本。 --- diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ca45bb9ba..9011cfb43 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2042,6 +2042,7 @@ pub(crate) async fn generate_editor_image_for_owner( delivery_width, delivery_height, removal.image, + None, ) .await; (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( - state: &AppState, - provider_source_object_key: &str, - provider_width: u32, - provider_height: u32, +// 中文注释:已经持有 provider 原图时的纯内存尺寸守卫,不发起任何 OSS 请求。 +fn apply_editor_postprocessed_alpha_with_provider_source_or_original( + provider_source: &DownloadedOpenAiImage, postprocessed: DownloadedOpenAiImage, ) -> (DownloadedOpenAiImage, Option) { - 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 { + let (postprocessed_width, postprocessed_height) = image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice())) .with_guessed_format() .map_err(|error| { @@ -3105,28 +3116,55 @@ async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_orig "message": format!("无法读取透明后处理图尺寸:{error}"), })) }) - }); - match postprocessed_dimensions { - Ok((postprocessed_width, postprocessed_height)) - if postprocessed_width == provider_width && postprocessed_height == provider_height => - { - return (postprocessed, None); - } - Ok(_) => {} + })?; + Ok(postprocessed_width == provider_width && postprocessed_height == provider_height) +} + +async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + state: &AppState, + provider_source_object_key: &str, + provider_width: u32, + provider_height: u32, + postprocessed: DownloadedOpenAiImage, + download_deadline: Option, +) -> (DownloadedOpenAiImage, Option) { + 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)), } // 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。 - let provider_source = - match download_editor_persisted_image_object(state, provider_source_object_key).await { - Ok(provider_source) => provider_source, - Err(error) => return (postprocessed, Some(error)), - }; - 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)), - } + // download_deadline 为像素规整降级路径提供绝对上界,避免在预算已耗尽后仍发起无界 GET; + // 非像素路径沿用既有语义传 None。 + let download = download_editor_persisted_image_object(state, provider_source_object_key); + let downloaded = match download_deadline { + Some(deadline) => { + match tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), download).await + { + Ok(downloaded) => downloaded, + 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 { @@ -3221,8 +3259,12 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( delivery_width: u32, delivery_height: u32, postprocessed: DownloadedOpenAiImage, + request_deadline: Option, reason: String, ) -> (DownloadedOpenAiImage, Option, Option) { + // 中文注释:只有尚未成功取得 provider 原图的降级分支才会走到这里。像素预算此时 + // 多半已经耗尽,不能拿它去绑这次回读(否则必然失败、守卫形同虚设),改用外层 + // 请求 deadline 作为绝对上界,保证这唯一一次 GET 有界。 let (image, dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, @@ -3230,11 +3272,39 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( delivery_width, delivery_height, postprocessed, + request_deadline, ) .await; (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, Option) { + 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( state: &AppState, provider_source_object_key: &str, @@ -3255,6 +3325,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ delivery_width, delivery_height, postprocessed, + request_deadline, "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), ) .await; @@ -3274,6 +3345,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ delivery_width, delivery_height, postprocessed, + request_deadline, reason, ) .await; @@ -3285,6 +3357,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ delivery_width, delivery_height, postprocessed, + request_deadline, "像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(), ) .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 { Ok(permit) => permit, Err(error) => { - return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, + return degrade_editor_pixel_art_with_provider_source( + &provider_source, delivery_width, delivery_height, postprocessed, error, - ) - .await; + ); } }; @@ -3356,39 +3427,27 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ Some(preparation_error), Some(error), ), - Ok(Ok(Err((None, None, error)))) => { - degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, - delivery_width, - delivery_height, - take_arc_downloaded_image(postprocessed), - error, - ) - .await - } - Ok(Err(error)) => { - degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, - delivery_width, - delivery_height, - take_arc_downloaded_image(postprocessed), - format!("像素规整工作线程异常:{error}"), - ) - .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 - } + Ok(Ok(Err((None, None, error)))) => degrade_editor_pixel_art_with_provider_source( + provider_source.as_ref(), + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + error, + ), + Ok(Err(error)) => degrade_editor_pixel_art_with_provider_source( + provider_source.as_ref(), + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + format!("像素规整工作线程异常:{error}"), + ), + Err(_) => degrade_editor_pixel_art_with_provider_source( + provider_source.as_ref(), + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + "像素规整处理超时,已保留透明后处理图。".to_string(), + ), } } @@ -4731,6 +4790,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( source_width, source_height, removal.image, + None, ) .await; (image, error, None) @@ -5506,6 +5566,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( source_width, source_height, removal.image, + None, ) .await; if let Some(error) = postprocess_dimension_error { @@ -9328,6 +9389,45 @@ mod tests { "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", "request_deadline,"], + ); // 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时 // 不产生额外 OSS GET,漂移修不好时返回尺寸错误。 assert_function_contains_in_order( @@ -12326,17 +12426,24 @@ mod tests { "async fn persist_editor_provider_source_resource", &["persist_editor_generated_image_owned"], ); + // 中文注释:尺寸一致时必须在回读 provider 原图之前就返回,只有漂移才发起 GET。 assert_function_contains_in_order( source, "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "fn restore_editor_generated_image_output_dimensions", &[ - "into_dimensions", - "return (postprocessed, None)", + "editor_postprocessed_alpha_matches_delivery_dimensions", + "Ok(true) => return (postprocessed, None)", "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 [ ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner",