收紧 Raw 图片编辑输入契约
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 3m51s
Project CI / Native shell tests (pull_request) Successful in 20m20s

新增 3840 边长、16 像素对齐、3:1 比例和总像素范围校验

要求 image 与 mask 为可完整解码的 PNG 并在扣费前拒绝非法输入

将 Raw 路由 JSON body limit 提升至 64 MiB

同步 Raw 图片编辑技术方案和边界测试
This commit is contained in:
2026-09-08 18:18:58 +08:00
parent 35d0db6377
commit b22eae2726
6 changed files with 191 additions and 25 deletions
@@ -1,6 +1,6 @@
# Raw GPT Image 2 图片编辑代理
更新时间:`2026-09-07`
更新时间:`2026-09-08`
## 目标
@@ -35,7 +35,18 @@ POST /api/raw/v1/images/edit
}
```
`image` 是必填的单图结构 `{ data, mimeType }`;`mask` 可选并使用相同结构。输入格式由 MIME 类型和解码后的图片字节共同确定,服务端不把输入格式另建成请求参数。`prompt` 必填。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。`width`、`height` 为整数,组成发送给 provider 的输出尺寸;不把尺寸改写成业务字符串字段。
`image` 是必填的单图结构 `{ data, mimeType }`;`mask` 可选并使用相同结构。`image` 和 `mask` 的 `mimeType` 必须为 `image/png`,base64 解码后必须是可完整解码的有效 PNG 文件;空数据、非 PNG 字节或 MIME 不匹配均在扣费前返回 400。服务端不把输入格式另建成请求参数。`prompt` 必填。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。
`width`、`height` 使用严格输出尺寸规则,均在扣费前校验:
1. 单边最大值为 `3840px`;
2. 宽、高均为 `16px` 的倍数;
3. 长边 / 短边不超过 `3:1`;
4. 总像素范围为 `655360` 至 `8294400`(含边界)。
校验通过后按整数尺寸发送给 provider,不静默 clamp 或改写调用者尺寸。
Raw 路由的 JSON body limit 为 `64 MiB`,为 base64 编码膨胀和可选 mask 留出空间;同时必须在 base64 解码后拒绝空 PNG,并保留图片格式校验,避免仅依赖 HTTP body limit。
服务端发送给 `platform-image` 时固定注入:
@@ -87,7 +98,7 @@ provider 结果统一解码为图片字节;每项结果的 MIME 与扩展名
## 代码拆分
- `server-rs/crates/api-server/src/raw_image.rs`:独立路由 handler、请求/响应 DTO、base64 输入校验、预检查和 raw billing 编排。
- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、provider 请求映射和原始响应解码;现有编辑器调用通过默认选项复用,不在业务 handler 复制 provider 协议。
- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和原始响应解码;不复用现有 editor 图片编辑 client 或其 multipart transport。
- `server-rs/crates/api-server/src/modules/raw.rs`:只注册 `/api/raw/v1/images/edit` 并挂载 Bearer middleware。
不修改 External v1 OpenAPI;不在 `external_editor_api.rs`、编辑器项目模块或外部生成 worker 中增加 raw 分支。
@@ -1,11 +1,14 @@
use axum::{Router, middleware, routing::post};
use axum::{Router, extract::DefaultBodyLimit, middleware, routing::post};
use crate::{auth::require_bearer_auth, raw_image::edit_raw_image, state::AppState};
const RAW_IMAGE_EDIT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new().route(
"/api/raw/v1/images/edit",
post(edit_raw_image)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth))
.layer(DefaultBodyLimit::max(RAW_IMAGE_EDIT_BODY_LIMIT_BYTES)),
)
}
+68 -9
View File
@@ -4,9 +4,14 @@ use axum::{
http::StatusCode,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_image::{RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit};
use image::{ImageFormat, ImageReader};
use platform_image::{
RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit,
validate_raw_image_edit_dimensions,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::io::Cursor;
use crate::{
asset_billing::{
@@ -122,9 +127,8 @@ fn prepare_request(payload: RawImageEditRequest) -> Result<PreparedRawImageEdit,
if payload.prompt.trim().is_empty() {
return Err(bad_request("prompt 不能为空"));
}
if payload.width == 0 || payload.height == 0 {
return Err(bad_request("width 和 height 必须为正整数"));
}
validate_raw_image_edit_dimensions(payload.width, payload.height)
.map_err(|error| bad_request(error.to_string()))?;
validate_optional_value(
payload.quality.as_deref(),
"quality",
@@ -186,16 +190,28 @@ fn validate_optional_value<const N: usize>(
fn decode_image(value: RawImageData, field: &str) -> Result<ReferenceImage, AppError> {
let mime_type = value.mime_type.trim().to_string();
if mime_type.is_empty() {
return Err(bad_request(format!("{field}.mimeType 不能为空")));
if !mime_type.eq_ignore_ascii_case("image/png") {
return Err(bad_request(format!("{field}.mimeType 必须为 image/png")));
}
let bytes = BASE64_STANDARD
.decode(value.data.trim())
.map_err(|_| bad_request(format!("{field}.data 必须是有效 base64")))?;
if bytes.is_empty() {
return Err(bad_request(format!("{field}.data 不能为空")));
}
let reader = ImageReader::new(Cursor::new(bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad_request(format!("{field}.data 必须是有效 PNG 文件")))?;
if reader.format() != Some(ImageFormat::Png) {
return Err(bad_request(format!("{field}.data 必须是有效 PNG 文件")));
}
reader
.decode()
.map_err(|_| bad_request(format!("{field}.data 必须是有效 PNG 文件")))?;
Ok(ReferenceImage {
bytes,
file_name: format!("{field}.png"),
mime_type,
mime_type: "image/png".to_string(),
})
}
@@ -225,18 +241,29 @@ fn bad_request(message: impl Into<String>) -> AppError {
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
fn encoded_png(width: u32, height: u32) -> String {
let image = RgbaImage::from_pixel(width, height, Rgba([255, 0, 0, 255]));
let mut bytes = Vec::new();
image
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
.expect("test PNG should encode");
BASE64_STANDARD.encode(bytes)
}
#[test]
fn request_uses_one_image_object_and_rejects_images_array() {
let payload = serde_json::json!({
"image": {"data": "aGVsbG8=", "mimeType": "image/png"},
"image": {"data": encoded_png(1, 1), "mimeType": "image/png"},
"prompt": "edit",
"width": 1024,
"height": 1024
});
let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("image object");
let prepared = prepare_request(parsed).expect("request should prepare");
assert_eq!(prepared.image.bytes, b"hello");
assert!(prepared.image.bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
let array_payload = serde_json::json!({
"images": [{"data": "aGVsbG8=", "mimeType": "image/png"}],
@@ -278,4 +305,36 @@ mod tests {
assert!(rendered.contains("image.data 必须是有效 base64"));
assert!(!rendered.contains("InvalidByte"));
}
#[test]
fn dimensions_follow_strict_raw_image_contract() {
assert!(validate_raw_image_edit_dimensions(1024, 1024).is_ok());
assert!(validate_raw_image_edit_dimensions(3840, 1280).is_ok());
assert!(validate_raw_image_edit_dimensions(3839, 1280).is_err());
assert!(validate_raw_image_edit_dimensions(3840, 1264).is_err());
assert!(validate_raw_image_edit_dimensions(1024, 1000).is_err());
assert!(validate_raw_image_edit_dimensions(16, 16).is_err());
assert!(validate_raw_image_edit_dimensions(3840, 3840).is_err());
}
#[test]
fn input_requires_decodable_png_and_png_mime() {
let valid = serde_json::json!({
"image": {"data": encoded_png(1, 1), "mimeType": "IMAGE/PNG"},
"prompt": "edit",
"width": 1024,
"height": 1024
});
assert!(prepare_request(serde_json::from_value(valid).expect("valid request")).is_ok());
for (data, mime_type) in [("aGVsbG8=", "image/png"), ("aGVsbG8=", "image/jpeg")] {
let payload = serde_json::json!({
"image": {"data": data, "mimeType": mime_type},
"prompt": "edit",
"width": 1024,
"height": 1024
});
assert!(prepare_request(serde_json::from_value(payload).expect("request")).is_err());
}
}
}
+7 -6
View File
@@ -10,15 +10,16 @@ pub use pixel_art_snapper::{
};
pub use vector_engine::{
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, RawImageEditOptions,
ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER,
VectorEngineImageSettings, build_vector_engine_image_http_client,
build_vector_engine_image_request_body,
PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint,
RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS,
RawImageEditDimensionError, RawImageEditOptions, ReferenceImage,
VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
create_vector_engine_image_edit_with_references,
create_vector_engine_image_edit_with_references_and_model,
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
create_vector_engine_nanobanana_generate_content, create_vector_engine_raw_image_edit,
download_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url,
vector_engine_nanobanana_generate_content_url,
download_remote_image, validate_raw_image_edit_dimensions, vector_engine_images_edit_url,
vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url,
};
@@ -26,7 +26,11 @@ pub use constants::{
};
pub use error::{PlatformImageError, PlatformImageStatusHint};
pub use image_source::download_remote_image;
pub use raw_edit::{RawImageEditOptions, create_vector_engine_raw_image_edit};
pub use raw_edit::{
RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS,
RawImageEditDimensionError, RawImageEditOptions, create_vector_engine_raw_image_edit,
validate_raw_image_edit_dimensions,
};
pub use request::{
build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model,
build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model,
@@ -13,7 +13,7 @@ use super::{
util::truncate_raw,
};
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct RawImageEditOptions {
pub quality: Option<String>,
pub background: Option<String>,
@@ -23,6 +23,68 @@ pub struct RawImageEditOptions {
pub mask: Option<ReferenceImage>,
}
pub const RAW_IMAGE_MAX_EDGE: u32 = 3_840;
pub const RAW_IMAGE_DIMENSION_ALIGNMENT: u32 = 16;
pub const RAW_IMAGE_MIN_PIXELS: u64 = 655_360;
pub const RAW_IMAGE_MAX_PIXELS: u64 = 8_294_400;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RawImageEditDimensionError {
Zero,
MaxEdge,
Alignment,
AspectRatio,
PixelCount,
}
impl std::fmt::Display for RawImageEditDimensionError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Zero => formatter.write_str("width 和 height 必须为正整数"),
Self::MaxEdge => write!(
formatter,
"width 和 height 的单边最大值为 {RAW_IMAGE_MAX_EDGE}px"
),
Self::Alignment => write!(
formatter,
"width 和 height 必须是 {RAW_IMAGE_DIMENSION_ALIGNMENT}px 的倍数"
),
Self::AspectRatio => formatter.write_str("长边与短边的比例不能超过 3:1"),
Self::PixelCount => write!(
formatter,
"总像素必须在 {RAW_IMAGE_MIN_PIXELS} 至 {RAW_IMAGE_MAX_PIXELS} 之间"
),
}
}
}
pub fn validate_raw_image_edit_dimensions(
width: u32,
height: u32,
) -> Result<(), RawImageEditDimensionError> {
if width == 0 || height == 0 {
return Err(RawImageEditDimensionError::Zero);
}
if width > RAW_IMAGE_MAX_EDGE || height > RAW_IMAGE_MAX_EDGE {
return Err(RawImageEditDimensionError::MaxEdge);
}
if !width.is_multiple_of(RAW_IMAGE_DIMENSION_ALIGNMENT)
|| !height.is_multiple_of(RAW_IMAGE_DIMENSION_ALIGNMENT)
{
return Err(RawImageEditDimensionError::Alignment);
}
let long_edge = u64::from(width.max(height));
let short_edge = u64::from(width.min(height));
if long_edge > short_edge.saturating_mul(3) {
return Err(RawImageEditDimensionError::AspectRatio);
}
let pixels = u64::from(width) * u64::from(height);
if !(RAW_IMAGE_MIN_PIXELS..=RAW_IMAGE_MAX_PIXELS).contains(&pixels) {
return Err(RawImageEditDimensionError::PixelCount);
}
Ok(())
}
/// Independent raw GPT Image 2 proxy; it does not call the editor image-edit client.
pub async fn create_vector_engine_raw_image_edit(
settings: &VectorEngineImageSettings,
@@ -31,6 +93,8 @@ pub async fn create_vector_engine_raw_image_edit(
options: RawImageEditOptions,
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
validate_raw_image_edit_dimensions(options.width, options.height)
.map_err(|error| invalid_request(failure_context, error.to_string()))?;
let url = vector_engine_images_edit_url(settings);
let started_at = Instant::now();
let prompt_chars = Some(prompt.chars().count());
@@ -392,9 +456,7 @@ mod tests {
Ok(("image/jpeg", "jpg"))
);
assert!(response_image_format(&json!({}), &json!({})).is_err());
assert!(
response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err()
);
assert!(response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err());
}
#[test]
@@ -417,4 +479,30 @@ mod tests {
assert_eq!(audit.reference_image_count, Some(2));
assert_eq!(audit.image_model, Some(GPT_IMAGE_2_MODEL));
}
#[test]
fn raw_image_edit_dimensions_enforce_strict_contract() {
assert!(validate_raw_image_edit_dimensions(1024, 640).is_ok());
assert!(validate_raw_image_edit_dimensions(3840, 2160).is_ok());
assert_eq!(
validate_raw_image_edit_dimensions(3856, 2160),
Err(RawImageEditDimensionError::MaxEdge)
);
assert_eq!(
validate_raw_image_edit_dimensions(1024, 1000),
Err(RawImageEditDimensionError::Alignment)
);
assert_eq!(
validate_raw_image_edit_dimensions(1936, 640),
Err(RawImageEditDimensionError::AspectRatio)
);
assert_eq!(
validate_raw_image_edit_dimensions(1024, 624),
Err(RawImageEditDimensionError::PixelCount)
);
assert_eq!(
validate_raw_image_edit_dimensions(3840, 2176),
Err(RawImageEditDimensionError::PixelCount)
);
}
}