From 35d0db6377a16b632354dd31ac88c6b1e6d11c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 8 Sep 2026 16:19:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Raw=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=AE=A1=E8=AE=A1=E4=B8=8E=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Raw provider 失败分支补齐结构化 failure audit 并接入 api-server 记录链 使用 VectorEngine 响应 output_format 生成返回图片 MIME 与扩展名 补充响应格式和审计字段定向测试并同步技术方案 --- ...¹案】Raw GPT Image 2图片编辑代理-2026-09-07.md | 2 +- server-rs/crates/api-server/src/raw_image.rs | 16 +- .../src/vector_engine/raw_edit.rs | 324 ++++++++++++++++-- 3 files changed, 300 insertions(+), 42 deletions(-) diff --git a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md index 759fe959d..397defd40 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -82,7 +82,7 @@ raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-ed `platform-image` 保留 VectorEngine 协议细节。raw handler 只负责:认证、JSON DTO、base64 解码、预检查、计费编排和响应映射。provider 请求仍由 `platform-image` 统一构造,并携带 `model`、`n`、`quality`、`background`、`output_format`、尺寸及图片参考字节。 -provider 结果统一解码为图片字节;raw handler 只将这些字节编码到 `data[].b64_json`。 +provider 结果统一解码为图片字节;每项结果的 MIME 与扩展名以 VectorEngine 响应中的真实 `output_format` 为准,不得从请求参数反推。发送、响应读取、上游状态、响应解析和缺图失败必须生成 `PlatformImageFailureAudit`,由 api-server 写入现有外部 API 失败审计链。raw handler 只将结果字节编码到 `data[].b64_json`。 ## 代码拆分 diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index c083a7fd7..ef4a22b33 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -14,7 +14,10 @@ use crate::{ }, auth::AuthenticatedAccessToken, http_error::AppError, - openai_image_generation::{map_platform_image_error, require_openai_image_settings}, + openai_image_generation::{ + map_platform_image_error, record_openai_image_failure_if_configured, + require_openai_image_settings, + }, request_context::RequestContext, state::AppState, }; @@ -65,8 +68,9 @@ pub(crate) async fn edit_raw_image( let user_id = authenticated.claims().user_id().to_string(); let request_id = request_context.request_id().to_string(); let points_cost = raw_image_edit_price(&state, prepared.width, prepared.height).await?; + let audit_settings = settings.clone(); let operation = async move { - let generated = create_vector_engine_raw_image_edit( + let generated = match create_vector_engine_raw_image_edit( &provider_settings, prepared.prompt.as_str(), &prepared.image, @@ -74,7 +78,13 @@ pub(crate) async fn edit_raw_image( "raw_image_edit", ) .await - .map_err(map_platform_image_error)?; + { + Ok(generated) => generated, + Err(error) => { + record_openai_image_failure_if_configured(&audit_settings, &error).await; + return Err(map_platform_image_error(error)); + } + }; let data = generated .images .into_iter() diff --git a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs index 435a9e1f3..a6c53ff0d 100644 --- a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs +++ b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs @@ -1,14 +1,16 @@ -use std::time::Duration; +use std::time::{Duration, Instant}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::multipart::{Form, Part}; use serde_json::Value; use super::{ + audit::build_failure_audit, constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}, error::PlatformImageError, request::vector_engine_images_edit_url, types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}, + util::truncate_raw, }; #[derive(Clone, Debug, Default)] @@ -30,6 +32,9 @@ pub async fn create_vector_engine_raw_image_edit( failure_context: &str, ) -> Result { let url = vector_engine_images_edit_url(settings); + let started_at = Instant::now(); + let prompt_chars = Some(prompt.chars().count()); + let reference_image_count = Some(1_usize + usize::from(options.mask.is_some())); let mut form = Form::new() .text("model", GPT_IMAGE_2_MODEL.to_string()) .text("n", "1".to_string()) @@ -72,50 +77,134 @@ pub async fn create_vector_engine_raw_image_edit( .multipart(form) .send() .await - .map_err(|error| request_error(&url, failure_context, error))?; + .map_err(|error| { + request_error( + &url, + failure_context, + "request_send", + error, + started_at, + prompt_chars, + reference_image_count, + ) + })?; let status = response.status(); - let body = response - .text() - .await - .map_err(|error| request_error(&url, failure_context, error))?; + let body = response.text().await.map_err(|error| { + request_error( + &url, + failure_context, + "response_read", + error, + started_at, + prompt_chars, + reference_image_count, + ) + })?; if !status.is_success() { + let message = format!( + "{failure_context}:上游图片编辑失败(HTTP {})", + status.as_u16() + ); + let raw_excerpt = truncate_raw(body.as_str()); + let audit = build_failure_audit( + url.as_str(), + failure_context, + "upstream_status", + Some(status.as_u16()), + Some(status_class(status.as_u16())), + false, + false, + message.as_str(), + None, + Some(raw_excerpt.clone()), + Some(started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + Some(GPT_IMAGE_2_MODEL), + ); return Err(PlatformImageError::Upstream { provider: VECTOR_ENGINE_PROVIDER, - message: format!( - "{failure_context}:上游图片编辑失败(HTTP {})", - status.as_u16() - ), + message, upstream_status: status.as_u16(), - raw_excerpt: body.chars().take(2_000).collect(), - audit: None, + raw_excerpt, + audit: Some(audit), }); } - let payload: Value = - serde_json::from_str(body.as_str()).map_err(|error| PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:上游响应不是 JSON:{error}"), - raw_excerpt: body.chars().take(2_000).collect(), - audit: None, - })?; + let payload: Value = match serde_json::from_str(body.as_str()) { + Ok(payload) => payload, + Err(error) => { + let message = format!("{failure_context}:上游响应不是 JSON:{error}"); + let audit = build_failure_audit( + url.as_str(), + failure_context, + "response_parse", + Some(status.as_u16()), + Some(status_class(status.as_u16())), + false, + false, + message.as_str(), + Some(error.to_string()), + Some(truncate_raw(body.as_str())), + Some(started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + Some(GPT_IMAGE_2_MODEL), + ); + return Err(PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message, + raw_excerpt: truncate_raw(body.as_str()), + audit: Some(audit), + }); + } + }; let mut images = Vec::new(); if let Some(entries) = payload.get("data").and_then(Value::as_array) { for entry in entries { let Some(value) = entry.get("b64_json").and_then(Value::as_str) else { continue; }; - let bytes = BASE64_STANDARD.decode(value).map_err(|error| { - PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:上游 b64_json 解码失败:{error}"), - raw_excerpt: body.chars().take(2_000).collect(), - audit: None, + let bytes = match BASE64_STANDARD.decode(value) { + Ok(bytes) => bytes, + Err(error) => { + let message = format!("{failure_context}:上游 b64_json 解码失败:{error}"); + let audit = build_failure_audit( + url.as_str(), + failure_context, + "response_parse", + Some(status.as_u16()), + Some(status_class(status.as_u16())), + false, + false, + message.as_str(), + Some(error.to_string()), + Some(truncate_raw(body.as_str())), + Some(started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + Some(GPT_IMAGE_2_MODEL), + ); + return Err(PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message, + raw_excerpt: truncate_raw(body.as_str()), + audit: Some(audit), + }); } - })?; - let (mime_type, extension) = match options.output_format.as_deref() { - Some("jpeg") => ("image/jpeg", "jpg"), - Some("webp") => ("image/webp", "webp"), - _ => ("image/png", "png"), }; + let (mime_type, extension) = + response_image_format(&payload, entry).map_err(|message| { + response_parse_error( + &url, + failure_context, + message, + status.as_u16(), + started_at, + prompt_chars, + reference_image_count, + &body, + ) + })?; images.push(DownloadedImage { bytes, mime_type: mime_type.to_string(), @@ -124,10 +213,27 @@ pub async fn create_vector_engine_raw_image_edit( } } if images.is_empty() { + let message = format!("{failure_context}:上游未返回 b64_json 图片"); + let audit = build_failure_audit( + url.as_str(), + failure_context, + "missing_image", + Some(status.as_u16()), + Some(status_class(status.as_u16())), + false, + false, + message.as_str(), + None, + Some(truncate_raw(body.as_str())), + Some(started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + Some(GPT_IMAGE_2_MODEL), + ); return Err(PlatformImageError::MissingImage { provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:上游未返回 b64_json 图片"), - audit: None, + message, + audit: Some(audit), }); } Ok(GeneratedImages { @@ -156,17 +262,159 @@ fn invalid_config(message: String) -> PlatformImageError { } } -fn request_error(url: &str, context: &str, error: E) -> PlatformImageError { +fn response_parse_error( + url: &str, + context: &str, + message: &str, + status: u16, + started_at: Instant, + prompt_chars: Option, + reference_image_count: Option, + body: &str, +) -> PlatformImageError { + let audit = build_failure_audit( + url, + context, + "response_parse", + Some(status), + Some(status_class(status)), + false, + false, + message, + None, + Some(truncate_raw(body)), + Some(started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + Some(GPT_IMAGE_2_MODEL), + ); + PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{context}:{message}"), + raw_excerpt: truncate_raw(body), + audit: Some(audit), + } +} + +fn response_image_format( + payload: &Value, + entry: &Value, +) -> Result<(&'static str, &'static str), &'static str> { + let Some(value) = entry + .get("output_format") + .or_else(|| payload.get("output_format")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err("上游响应缺少 output_format"); + }; + match value.to_ascii_lowercase().as_str() { + "png" => Ok(("image/png", "png")), + "jpeg" | "jpg" => Ok(("image/jpeg", "jpg")), + "webp" => Ok(("image/webp", "webp")), + "gif" => Ok(("image/gif", "gif")), + _ => Err("上游响应包含不支持的 output_format"), + } +} + +fn request_error( + url: &str, + context: &str, + failure_stage: &'static str, + error: reqwest::Error, + started_at: Instant, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageError { + let timeout = error.is_timeout(); + let connect = error.is_connect(); + let source = error.to_string(); + let message = format!("{context}:上游请求失败:{source}"); + let audit = build_failure_audit( + url, + context, + failure_stage, + None, + Some("transport"), + timeout, + connect, + message.as_str(), + Some(source.clone()), + None, + Some(started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + Some(GPT_IMAGE_2_MODEL), + ); PlatformImageError::Request { provider: VECTOR_ENGINE_PROVIDER, - message: format!("{context}:上游请求失败:{error}"), + message, endpoint: Some(url.to_string()), - timeout: false, - connect: false, + timeout, + connect, request: true, body: false, status_code: None, - source: Some(error.to_string()), - audit: None, + source: Some(source), + audit: Some(audit), + } +} + +fn status_class(status: u16) -> &'static str { + match status { + 100..=199 => "1xx", + 200..=299 => "2xx", + 300..=399 => "3xx", + 400..=499 => "4xx", + _ => "5xx", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn response_format_uses_vector_engine_output_format() { + let payload = json!({"output_format": "png"}); + assert_eq!( + response_image_format(&payload, &json!({"output_format": "webp"})), + Ok(("image/webp", "webp")) + ); + assert_eq!( + response_image_format(&payload, &json!({})), + Ok(("image/png", "png")) + ); + assert_eq!( + response_image_format(&json!({}), &json!({"output_format": "jpeg"})), + Ok(("image/jpeg", "jpg")) + ); + assert!(response_image_format(&json!({}), &json!({})).is_err()); + assert!( + response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err() + ); + } + + #[test] + fn response_parse_error_contains_structured_audit() { + let error = response_parse_error( + "https://vector.example/v1/images/edits", + "raw_image_edit", + "上游响应缺少 output_format", + 200, + Instant::now(), + Some(12), + Some(2), + "{\"data\":[]}", + ); + let audit = error.audit().expect("response error should carry audit"); + assert_eq!(audit.failure_stage, "response_parse"); + assert_eq!(audit.status_code, Some(200)); + assert_eq!(audit.status_class, Some("2xx")); + assert_eq!(audit.prompt_chars, Some(12)); + assert_eq!(audit.reference_image_count, Some(2)); + assert_eq!(audit.image_model, Some(GPT_IMAGE_2_MODEL)); } }