在扣费前校验 Raw mask 尺寸

解码时保留 image 与 mask 宽高并拒绝不一致输入

同步 Raw 图片编辑请求合同与回归测试
This commit is contained in:
2026-09-09 11:45:44 +08:00
parent 6c77de4a36
commit 40316d82c4
2 changed files with 40 additions and 11 deletions
@@ -35,7 +35,7 @@ POST /api/raw/v1/images/edit
}
```
`image` 是必填的单图结构 `{ data, mimeType }``mask` 可选并使用相同结构。`image``mask``mimeType` 必须为 `image/png`,base64 解码后必须是可完整解码的有效 PNG 文件;空数据、非 PNG 字节MIME 不匹配均在扣费前返回 400。服务端不把输入格式另建成请求参数。`prompt` 必填。`quality``background``output_format` 采用 GPT Image 模型支持的值。
`image` 是必填的单图结构 `{ data, mimeType }``mask` 可选并使用相同结构。`image``mask``mimeType` 必须为 `image/png`,base64 解码后必须是可完整解码的有效 PNG 文件,提供 mask 时其宽高必须与 image 完全一致;空数据、非 PNG 字节MIME 不匹配或尺寸不一致均在扣费前返回 400。服务端不把输入格式另建成请求参数。`prompt` 必填。`quality``background``output_format` 采用 GPT Image 模型支持的值。
`width``height` 使用严格输出尺寸规则,均在扣费前校验:
+39 -10
View File
@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{ImageFormat, ImageReader};
use image::{GenericImageView, ImageFormat, ImageReader};
use platform_image::{
RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RawImageEditOptions, ReferenceImage,
create_vector_engine_raw_image_edit, validate_raw_image_edit_dimensions,
@@ -176,10 +176,16 @@ fn prepare_request(payload: RawImageEditRequest) -> Result<PreparedRawImageEdit,
let quality = normalize_optional(payload.quality);
let background = normalize_optional(payload.background);
let output_format = normalize_optional(payload.output_format);
let image = decode_image(payload.image, "image")?;
let (image, image_width, image_height) = decode_image(payload.image, "image")?;
let mask = payload
.mask
.map(|value| decode_image(value, "mask"))
.map(|value| {
let (mask, mask_width, mask_height) = decode_image(value, "mask")?;
if mask_width != image_width || mask_height != image_height {
return Err(bad_request("mask 尺寸必须与 image 一致"));
}
Ok(mask)
})
.transpose()?;
Ok(PreparedRawImageEdit {
image,
@@ -217,7 +223,7 @@ fn validate_optional_value<const N: usize>(
Err(bad_request(format!("{field} 值无效")))
}
fn decode_image(value: RawImageData, field: &str) -> Result<ReferenceImage, AppError> {
fn decode_image(value: RawImageData, field: &str) -> Result<(ReferenceImage, u32, u32), AppError> {
let mime_type = value.mime_type.trim().to_string();
if !mime_type.eq_ignore_ascii_case("image/png") {
return Err(bad_request(format!("{field}.mimeType 必须为 image/png")));
@@ -239,14 +245,19 @@ fn decode_image(value: RawImageData, field: &str) -> Result<ReferenceImage, AppE
if reader.format() != Some(ImageFormat::Png) {
return Err(bad_request(format!("{field}.data 必须是有效 PNG 文件")));
}
reader
let decoded = reader
.decode()
.map_err(|error| map_decode_image_error(field, error))?;
Ok(ReferenceImage {
bytes,
file_name: format!("{field}.png"),
mime_type: "image/png".to_string(),
})
let (width, height) = decoded.dimensions();
Ok((
ReferenceImage {
bytes,
file_name: format!("{field}.png"),
mime_type: "image/png".to_string(),
},
width,
height,
))
}
fn map_decode_image_error(field: &str, error: image::ImageError) -> AppError {
@@ -401,4 +412,22 @@ mod tests {
assert!(format!("{error:?}").contains("超出 PNG 尺寸或解码资源上限"));
}
#[test]
fn mask_must_match_source_image_dimensions() {
let payload = serde_json::json!({
"image": {"data": encoded_png(2, 1), "mimeType": "image/png"},
"mask": {"data": encoded_png(1, 1), "mimeType": "image/png"},
"prompt": "edit",
"width": 1024,
"height": 1024
});
let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("request");
let error = match prepare_request(parsed) {
Ok(_) => panic!("mismatched mask should fail before billing"),
Err(error) => error,
};
assert!(format!("{error:?}").contains("mask 尺寸必须与 image 一致"));
}
}