refactor: extract platform image provider
This commit is contained in:
@@ -34,6 +34,7 @@ module-story = { workspace = true }
|
||||
module-visual-novel = { workspace = true }
|
||||
platform-agent = { workspace = true }
|
||||
platform-auth = { workspace = true }
|
||||
platform-image = { workspace = true }
|
||||
platform-llm = { workspace = true }
|
||||
platform-oss = { workspace = true }
|
||||
platform-speech = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_image::PlatformImageFailureAudit;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
@@ -109,6 +110,28 @@ impl ExternalApiFailureDraft {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
|
||||
audit: &PlatformImageFailureAudit,
|
||||
) -> ExternalApiFailureDraft {
|
||||
ExternalApiFailureDraft::new(
|
||||
audit.provider,
|
||||
audit.endpoint.clone(),
|
||||
audit.operation.clone(),
|
||||
audit.failure_stage,
|
||||
audit.error_message.clone(),
|
||||
)
|
||||
.with_status_code(audit.status_code)
|
||||
.with_optional_status_class(audit.status_class)
|
||||
.with_timeout(audit.timeout)
|
||||
.with_retryable(audit.retryable)
|
||||
.with_error_source(audit.error_source.clone())
|
||||
.with_raw_excerpt(audit.raw_excerpt.clone())
|
||||
.with_latency_ms(audit.latency_ms)
|
||||
.with_prompt_chars(audit.prompt_chars)
|
||||
.with_reference_image_count(audit.reference_image_count)
|
||||
.with_image_model(audit.image_model)
|
||||
}
|
||||
|
||||
/// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
|
||||
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
|
||||
status_class(Some(status_code.as_u16()))
|
||||
|
||||
@@ -113,6 +113,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
StatusCode::GATEWAY_TIMEOUT => ("GATEWAY_TIMEOUT", "上游服务请求超时"),
|
||||
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
|
||||
StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"),
|
||||
_ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -103,7 +103,7 @@ use crate::{
|
||||
},
|
||||
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||
request_context::RequestContext,
|
||||
state::PuzzleApiState,
|
||||
state::{AppState, PuzzleApiState},
|
||||
work_author::resolve_puzzle_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::openai_image_generation::GPT_IMAGE_2_MODEL;
|
||||
use crate::openai_image_generation::{GPT_IMAGE_2_MODEL, map_platform_image_error};
|
||||
use platform_image::{PlatformImageError, VECTOR_ENGINE_PROVIDER};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_image_size_is_square_1_1() {
|
||||
@@ -218,45 +220,6 @@ fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
|
||||
assert!(body.get("image").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_url_normalizes_base_url() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
puzzle_vector_engine_images_generation_url(&settings),
|
||||
"https://vector.example/v1/images/generations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_normalizes_base_url() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
puzzle_vector_engine_images_edit_url(&settings),
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
|
||||
let images = puzzle_images_from_base64(
|
||||
"edit-1".to_string(),
|
||||
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(images.images.len(), 1);
|
||||
assert_eq!(images.images[0].mime_type, "image/png");
|
||||
assert_eq!(images.images[0].extension, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
|
||||
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
|
||||
@@ -379,9 +342,18 @@ fn puzzle_asset_object_reference_requires_matching_owner() {
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
"创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(),
|
||||
);
|
||||
let error = map_platform_image_error(PlatformImageError::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: "创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(),
|
||||
endpoint: Some("https://vector.example/v1/images/generations".to_string()),
|
||||
timeout: true,
|
||||
connect: false,
|
||||
request: true,
|
||||
body: false,
|
||||
status_code: None,
|
||||
source: None,
|
||||
audit: None,
|
||||
});
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
@@ -389,11 +361,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片生成任务失败",
|
||||
);
|
||||
let error = map_platform_image_error(PlatformImageError::Upstream {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: "VectorEngine generation endpoint timeout".to_string(),
|
||||
upstream_status: reqwest::StatusCode::GATEWAY_TIMEOUT.as_u16(),
|
||||
raw_excerpt: r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#
|
||||
.to_string(),
|
||||
audit: None,
|
||||
});
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use crate::openai_image_generation::{
|
||||
OpenAiReferenceImage, create_openai_image_edit_with_references,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PuzzleImageModel {
|
||||
@@ -26,6 +29,8 @@ impl PuzzleImageModel {
|
||||
pub(crate) struct PuzzleVectorEngineSettings {
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) request_timeout_ms: u64,
|
||||
pub(crate) external_api_audit_state: Option<AppState>,
|
||||
}
|
||||
|
||||
pub(crate) struct PuzzleGeneratedImages {
|
||||
@@ -78,6 +83,25 @@ impl PuzzleDownloadedImage {
|
||||
bytes: image.bytes,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_openai_image(image: DownloadedOpenAiImage) -> Self {
|
||||
Self {
|
||||
extension: image.extension,
|
||||
mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()),
|
||||
bytes: image.bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PuzzleVectorEngineSettings {
|
||||
fn to_openai_settings(&self) -> crate::openai_image_generation::OpenAiImageSettings {
|
||||
crate::openai_image_generation::OpenAiImageSettings {
|
||||
base_url: self.base_url.clone(),
|
||||
api_key: self.api_key.clone(),
|
||||
request_timeout_ms: self.request_timeout_ms,
|
||||
external_api_audit_state: self.external_api_audit_state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||||
@@ -151,27 +175,18 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
Ok(PuzzleVectorEngineSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1),
|
||||
external_api_audit_state: Some(state.root_state().clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_image_http_client(
|
||||
state: &PuzzleApiState,
|
||||
image_model: PuzzleImageModel,
|
||||
_image_model: PuzzleImageModel,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
let provider = image_model.provider_name();
|
||||
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
|
||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||||
// 中文注释:参考图走 multipart edits;强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
build_openai_image_http_client(&settings.to_openai_settings())
|
||||
}
|
||||
|
||||
pub(crate) fn to_puzzle_generated_image_candidate(
|
||||
@@ -213,198 +228,66 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
.await;
|
||||
}
|
||||
|
||||
let request_body = build_puzzle_vector_engine_image_request_body(
|
||||
image_model,
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
&settings.to_openai_settings(),
|
||||
prompt,
|
||||
negative_prompt,
|
||||
Some(negative_prompt),
|
||||
size,
|
||||
candidate_count,
|
||||
reference_image,
|
||||
);
|
||||
let request_url = puzzle_vector_engine_images_generation_url(settings);
|
||||
let request_started_at = Instant::now();
|
||||
let response = http_client
|
||||
.post(request_url.as_str())
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"创建拼图 VectorEngine 图片生成任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = image_model.request_model_name(),
|
||||
endpoint = %request_url,
|
||||
status = status.as_u16(),
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size,
|
||||
has_reference_image = reference_image.is_some(),
|
||||
elapsed_ms = upstream_elapsed_ms,
|
||||
"拼图 VectorEngine 图片生成 HTTP 返回"
|
||||
);
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"读取拼图 VectorEngine 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_puzzle_vector_engine_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
"创建拼图 VectorEngine 图片生成任务失败",
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_puzzle_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析拼图 VectorEngine 图片生成响应失败",
|
||||
)?;
|
||||
let image_urls = extract_puzzle_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
let download_started_at = Instant::now();
|
||||
let images = download_puzzle_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
candidate_count,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = image_model.request_model_name(),
|
||||
image_count = images.images.len(),
|
||||
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
|
||||
"拼图 VectorEngine 图片下载完成"
|
||||
);
|
||||
return Ok(images);
|
||||
}
|
||||
|
||||
let b64_images = extract_puzzle_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(puzzle_images_from_base64(
|
||||
format!("vector-engine-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
candidate_count,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图 VectorEngine 图片生成未返回图片地址",
|
||||
})),
|
||||
&[],
|
||||
"拼图 VectorEngine 图片生成失败",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(PuzzleGeneratedImages {
|
||||
task_id: generated.task_id,
|
||||
images: generated
|
||||
.images
|
||||
.into_iter()
|
||||
.map(PuzzleDownloadedImage::from_openai_image)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleVectorEngineSettings,
|
||||
image_model: PuzzleImageModel,
|
||||
_image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: &PuzzleResolvedReferenceImage,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let request_url = puzzle_vector_engine_images_edit_url(settings);
|
||||
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
|
||||
let file_name = format!(
|
||||
"puzzle-reference.{}",
|
||||
puzzle_mime_to_extension(reference_image.mime_type.as_str())
|
||||
);
|
||||
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
|
||||
.file_name(file_name)
|
||||
.mime_str(reference_image.mime_type.as_str())
|
||||
.map_err(|error| {
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"构造拼图 VectorEngine 图片编辑参考图失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("image", image_part)
|
||||
.text("model", image_model.request_model_name().to_string())
|
||||
.text(
|
||||
"prompt",
|
||||
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
|
||||
)
|
||||
.text("n", candidate_count.clamp(1, 1).to_string())
|
||||
.text("size", size.to_string());
|
||||
let request_started_at = Instant::now();
|
||||
let response = http_client
|
||||
.post(request_url.as_str())
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = image_model.request_model_name(),
|
||||
endpoint = %request_url,
|
||||
status = status.as_u16(),
|
||||
prompt_chars = prompt.chars().count(),
|
||||
let generated = create_openai_image_edit_with_references(
|
||||
http_client,
|
||||
&settings.to_openai_settings(),
|
||||
prompt,
|
||||
Some(negative_prompt),
|
||||
size,
|
||||
reference_mime = %reference_image.mime_type,
|
||||
reference_bytes = reference_image.bytes_len,
|
||||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||
"拼图 VectorEngine 图片编辑 HTTP 返回"
|
||||
);
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"读取拼图 VectorEngine 图片编辑响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_puzzle_vector_engine_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_puzzle_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析拼图 VectorEngine 图片编辑响应失败",
|
||||
)?;
|
||||
let image_urls = extract_puzzle_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count)
|
||||
.await;
|
||||
}
|
||||
let b64_images = extract_puzzle_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(puzzle_images_from_base64(
|
||||
task_id,
|
||||
b64_images,
|
||||
candidate_count,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图 VectorEngine 图片编辑未返回图片",
|
||||
})),
|
||||
candidate_count,
|
||||
&[OpenAiReferenceImage {
|
||||
bytes: reference_image.bytes.clone(),
|
||||
mime_type: reference_image.mime_type.clone(),
|
||||
file_name,
|
||||
}],
|
||||
"拼图 VectorEngine 图片编辑失败",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(PuzzleGeneratedImages {
|
||||
task_id: generated.task_id,
|
||||
images: generated
|
||||
.images
|
||||
.into_iter()
|
||||
.map(PuzzleDownloadedImage::from_openai_image)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_downloaded_image_reference(
|
||||
@@ -569,42 +452,6 @@ pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
pub(crate) fn puzzle_vector_engine_images_generation_url(
|
||||
settings: &PuzzleVectorEngineSettings,
|
||||
) -> String {
|
||||
if settings.base_url.ends_with("/v1") {
|
||||
format!("{}/images/generations", settings.base_url)
|
||||
} else {
|
||||
format!("{}/v1/images/generations", settings.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn puzzle_vector_engine_images_edit_url(
|
||||
settings: &PuzzleVectorEngineSettings,
|
||||
) -> String {
|
||||
if settings.base_url.ends_with("/v1") {
|
||||
format!("{}/images/edits", settings.base_url)
|
||||
} else {
|
||||
format!("{}/v1/images/edits", settings.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn download_puzzle_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
Ok(PuzzleGeneratedImages { task_id, images })
|
||||
}
|
||||
|
||||
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
|
||||
source
|
||||
.trim()
|
||||
@@ -890,40 +737,6 @@ async fn download_signed_puzzle_reference_image(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn download_puzzle_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
) -> Result<PuzzleDownloadedImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let bytes = response.bytes().await.map_err(|error| {
|
||||
map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "puzzle-image",
|
||||
"message": "下载拼图正式图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(PuzzleDownloadedImage {
|
||||
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: bytes.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_generated_asset(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
@@ -1197,18 +1010,6 @@ pub(crate) fn build_puzzle_level_asset_metadata(
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn parse_puzzle_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("{fallback_message}:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
|
||||
let body = value.strip_prefix("data:")?;
|
||||
let (mime_type, data) = body.split_once(";base64,")?;
|
||||
@@ -1249,49 +1050,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_puzzle_strings_by_key(payload, "image", &mut urls);
|
||||
collect_puzzle_strings_by_key(payload, "url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(crate) fn extract_puzzle_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_puzzle_strings_by_key(payload, "b64_json", &mut values);
|
||||
values
|
||||
}
|
||||
|
||||
pub(crate) fn puzzle_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> PuzzleGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
.filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str()))
|
||||
.collect();
|
||||
|
||||
PuzzleGeneratedImages { task_id, images }
|
||||
}
|
||||
|
||||
pub(crate) fn decode_puzzle_generated_image_base64(raw: &str) -> Option<PuzzleDownloadedImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_puzzle_image_mime_type(bytes.as_slice());
|
||||
Some(PuzzleDownloadedImage {
|
||||
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_puzzle_strings_by_key(payload, target_key, &mut results);
|
||||
@@ -1333,22 +1091,6 @@ pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<St
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||||
return "image/png".to_string();
|
||||
}
|
||||
if bytes.starts_with(b"\xFF\xD8\xFF") {
|
||||
return "image/jpeg".to_string();
|
||||
}
|
||||
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
|
||||
return "image/webp".to_string();
|
||||
}
|
||||
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
return "image/gif".to_string();
|
||||
}
|
||||
"image/png".to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
@@ -1387,21 +1129,6 @@ pub(crate) fn map_puzzle_image_request_error(message: String) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppError {
|
||||
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message,
|
||||
"timeout": is_timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("timed out")
|
||||
@@ -1410,64 +1137,6 @@ pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|
||||
|| lower.contains("deadline has elapsed")
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_vector_engine_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
|
||||
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|
||||
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
timeout = is_timeout,
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"拼图 VectorEngine 上游请求失败"
|
||||
);
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
"timeout": is_timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
|
||||
&& let Some(message) = find_first_puzzle_string_by_key(&payload, "message")
|
||||
{
|
||||
return message;
|
||||
}
|
||||
fallback_message.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
let normalized = raw_text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.chars().count() <= max_chars {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
let keep_chars = max_chars.saturating_sub(3);
|
||||
format!(
|
||||
"{}...",
|
||||
normalized.chars().take(keep_chars).collect::<String>()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
map_oss_error(error, "aliyun-oss")
|
||||
}
|
||||
|
||||
12
server-rs/crates/platform-image/Cargo.toml
Normal file
12
server-rs/crates/platform-image/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "platform-image"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing = { workspace = true }
|
||||
1362
server-rs/crates/platform-image/src/lib.rs
Normal file
1362
server-rs/crates/platform-image/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user