Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
1284 lines
42 KiB
Rust
1284 lines
42 KiB
Rust
use super::*;
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub(crate) enum PuzzleImageModel {
|
||
GptImage2,
|
||
Gemini31FlashPreview,
|
||
}
|
||
|
||
impl PuzzleImageModel {
|
||
pub(crate) fn provider_name(self) -> &'static str {
|
||
VECTOR_ENGINE_PROVIDER
|
||
}
|
||
|
||
pub(crate) fn request_model_name(self) -> &'static str {
|
||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
|
||
}
|
||
|
||
pub(crate) fn candidate_source_type(self) -> &'static str {
|
||
match self {
|
||
Self::GptImage2 => "generated:gpt-image-2",
|
||
Self::Gemini31FlashPreview => "generated:nanobanana2",
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) struct PuzzleVectorEngineSettings {
|
||
pub(crate) base_url: String,
|
||
pub(crate) api_key: String,
|
||
}
|
||
|
||
pub(crate) struct PuzzleGeneratedImages {
|
||
pub(crate) task_id: String,
|
||
pub(crate) images: Vec<PuzzleDownloadedImage>,
|
||
}
|
||
|
||
pub(crate) struct PuzzleResolvedReferenceImage {
|
||
pub(crate) mime_type: String,
|
||
pub(crate) bytes_len: usize,
|
||
pub(crate) bytes: Vec<u8>,
|
||
}
|
||
|
||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||
pub(crate) record: PuzzleGeneratedImageCandidateRecord,
|
||
pub(crate) downloaded_image: PuzzleDownloadedImage,
|
||
}
|
||
|
||
impl GeneratedPuzzleImageCandidate {
|
||
pub(crate) fn into_record(self) -> PuzzleGeneratedImageCandidateRecord {
|
||
self.record
|
||
}
|
||
}
|
||
|
||
pub(crate) trait GeneratedPuzzleImageCandidatesExt {
|
||
fn into_records(self) -> Vec<PuzzleGeneratedImageCandidateRecord>;
|
||
}
|
||
|
||
impl GeneratedPuzzleImageCandidatesExt for Vec<GeneratedPuzzleImageCandidate> {
|
||
fn into_records(self) -> Vec<PuzzleGeneratedImageCandidateRecord> {
|
||
self.into_iter()
|
||
.map(GeneratedPuzzleImageCandidate::into_record)
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub(crate) struct PuzzleDownloadedImage {
|
||
pub(crate) extension: String,
|
||
pub(crate) mime_type: String,
|
||
pub(crate) bytes: Vec<u8>,
|
||
}
|
||
|
||
impl PuzzleDownloadedImage {
|
||
pub(crate) fn from_resolved_reference_image(image: PuzzleResolvedReferenceImage) -> Self {
|
||
Self {
|
||
extension: puzzle_mime_to_extension(image.mime_type.as_str()).to_string(),
|
||
mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()),
|
||
bytes: image.bytes,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||
pub(crate) mime_type: String,
|
||
pub(crate) bytes: Vec<u8>,
|
||
}
|
||
|
||
pub(crate) struct GeneratedPuzzleAssetResponse {
|
||
pub(crate) image_src: String,
|
||
pub(crate) asset_id: String,
|
||
}
|
||
|
||
pub(crate) struct GeneratedPuzzleUiBackgroundResponse {
|
||
pub(crate) image_src: String,
|
||
pub(crate) object_key: String,
|
||
}
|
||
|
||
pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
|
||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
|
||
tracing::warn!(
|
||
requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW,
|
||
effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
|
||
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all"
|
||
);
|
||
PuzzleImageModel::Gemini31FlashPreview
|
||
}
|
||
_ => PuzzleImageModel::GptImage2,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn require_puzzle_vector_engine_settings(
|
||
state: &AppState,
|
||
) -> Result<PuzzleVectorEngineSettings, AppError> {
|
||
let base_url = state
|
||
.config
|
||
.vector_engine_base_url
|
||
.trim()
|
||
.trim_end_matches('/');
|
||
if base_url.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": VECTOR_ENGINE_PROVIDER,
|
||
"message": "VectorEngine 图片生成地址未配置",
|
||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let api_key = state
|
||
.config
|
||
.vector_engine_api_key
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": VECTOR_ENGINE_PROVIDER,
|
||
"message": "VectorEngine 图片生成密钥未配置",
|
||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||
}))
|
||
})?;
|
||
|
||
Ok(PuzzleVectorEngineSettings {
|
||
base_url: base_url.to_string(),
|
||
api_key: api_key.to_string(),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_image_http_client(
|
||
state: &AppState,
|
||
image_model: PuzzleImageModel,
|
||
) -> Result<reqwest::Client, AppError> {
|
||
let provider = image_model.provider_name();
|
||
let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms;
|
||
|
||
reqwest::Client::builder()
|
||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||
// 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。
|
||
.http1_only()
|
||
.build()
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": provider,
|
||
"message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"),
|
||
}))
|
||
})
|
||
}
|
||
|
||
pub(crate) fn to_puzzle_generated_image_candidate(
|
||
candidate: &PuzzleGeneratedImageCandidateRecord,
|
||
) -> PuzzleGeneratedImageCandidate {
|
||
// SpacetimeDB 模块反序列化的是 module-puzzle 的持久化结构,必须保留 snake_case 字段名;HTTP 响应层再单独映射为 camelCase。
|
||
PuzzleGeneratedImageCandidate {
|
||
candidate_id: candidate.candidate_id.clone(),
|
||
image_src: candidate.image_src.clone(),
|
||
asset_id: candidate.asset_id.clone(),
|
||
prompt: candidate.prompt.clone(),
|
||
actual_prompt: candidate.actual_prompt.clone(),
|
||
source_type: candidate.source_type.clone(),
|
||
selected: candidate.selected,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||
http_client: &reqwest::Client,
|
||
settings: &PuzzleVectorEngineSettings,
|
||
image_model: PuzzleImageModel,
|
||
prompt: &str,
|
||
negative_prompt: &str,
|
||
size: &str,
|
||
candidate_count: u32,
|
||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||
let request_body = build_puzzle_vector_engine_image_request_body(
|
||
image_model,
|
||
prompt,
|
||
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);
|
||
}
|
||
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": VECTOR_ENGINE_PROVIDER,
|
||
"message": "拼图 VectorEngine 图片生成未返回图片地址",
|
||
})),
|
||
)
|
||
}
|
||
|
||
pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||
http_client: &reqwest::Client,
|
||
settings: &PuzzleVectorEngineSettings,
|
||
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", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.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_reqwest_error(
|
||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||
&request_url,
|
||
error,
|
||
)
|
||
})?;
|
||
let status = response.status();
|
||
tracing::info!(
|
||
provider = VECTOR_ENGINE_PROVIDER,
|
||
image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL,
|
||
endpoint = %request_url,
|
||
status = status.as_u16(),
|
||
prompt_chars = prompt.chars().count(),
|
||
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 图片编辑未返回图片",
|
||
})),
|
||
)
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||
image_model: PuzzleImageModel,
|
||
prompt: &str,
|
||
negative_prompt: &str,
|
||
size: &str,
|
||
candidate_count: u32,
|
||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||
) -> Value {
|
||
let mut body = Map::from_iter([
|
||
(
|
||
"model".to_string(),
|
||
Value::String(image_model.request_model_name().to_string()),
|
||
),
|
||
(
|
||
"prompt".to_string(),
|
||
Value::String(build_puzzle_vector_engine_prompt(prompt, negative_prompt)),
|
||
),
|
||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||
("size".to_string(), Value::String(size.to_string())),
|
||
]);
|
||
if let Some(reference_image) = reference_image
|
||
&& let Some(reference_data_url) =
|
||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||
{
|
||
body.insert("image".to_string(), json!([reference_data_url]));
|
||
}
|
||
|
||
Value::Object(body)
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_vector_engine_generation_prompt(
|
||
prompt: &str,
|
||
has_reference_image: bool,
|
||
) -> String {
|
||
let prompt = prompt.trim();
|
||
if !has_reference_image {
|
||
return prompt.to_string();
|
||
}
|
||
|
||
format!(
|
||
concat!(
|
||
"请以随请求提供的参考图作为第一优先级生成依据,严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围;",
|
||
"允许按下面文字要求做风格化和细节增强,但不要改成与参考图无关的新画面。\n",
|
||
"{prompt}"
|
||
),
|
||
prompt = prompt,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_generation_reference_image_data_url(
|
||
image: &PuzzleResolvedReferenceImage,
|
||
) -> Option<String> {
|
||
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
|
||
.unwrap_or_else(|| image.bytes.clone());
|
||
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||
"image/png"
|
||
} else {
|
||
image.mime_type.as_str()
|
||
};
|
||
|
||
Some(format!(
|
||
"data:{};base64,{}",
|
||
normalize_puzzle_downloaded_image_mime_type(mime_type),
|
||
BASE64_STANDARD.encode(bytes)
|
||
))
|
||
}
|
||
|
||
pub(crate) fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||
let image = image::load_from_memory(bytes).ok()?;
|
||
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
|
||
Some(cursor.into_inner())
|
||
}
|
||
|
||
pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||
reference_image_src
|
||
.map(str::trim)
|
||
.map(|value| !value.is_empty())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub(crate) fn collect_puzzle_reference_image_sources(
|
||
legacy_reference_image_src: Option<&str>,
|
||
reference_image_srcs: &[String],
|
||
) -> Vec<String> {
|
||
let mut sources = Vec::new();
|
||
for source in legacy_reference_image_src
|
||
.into_iter()
|
||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||
{
|
||
let normalized = source.trim();
|
||
if normalized.is_empty() {
|
||
continue;
|
||
}
|
||
if !sources
|
||
.iter()
|
||
.any(|existing: &String| existing == normalized)
|
||
{
|
||
sources.push(normalized.to_string());
|
||
}
|
||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||
break;
|
||
}
|
||
}
|
||
sources
|
||
}
|
||
|
||
pub(crate) fn has_puzzle_reference_images(
|
||
legacy_reference_image_src: Option<&str>,
|
||
reference_image_srcs: &[String],
|
||
) -> bool {
|
||
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
|
||
.is_empty()
|
||
}
|
||
|
||
pub(crate) fn should_use_puzzle_reference_image_edit(
|
||
reference_image_src: Option<&str>,
|
||
use_reference_image_edit: bool,
|
||
) -> bool {
|
||
use_reference_image_edit && has_puzzle_reference_image(reference_image_src)
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||
let prompt = prompt.trim();
|
||
let negative_prompt = negative_prompt.trim();
|
||
if negative_prompt.is_empty() {
|
||
return prompt.to_string();
|
||
}
|
||
|
||
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) async fn resolve_puzzle_reference_image_as_data_url(
|
||
state: &AppState,
|
||
http_client: &reqwest::Client,
|
||
source: &str,
|
||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||
let trimmed = source.trim();
|
||
if trimmed.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "puzzle",
|
||
"field": "referenceImageSrc",
|
||
"message": "参考图不能为空。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||
let bytes_len = parsed.bytes.len();
|
||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "puzzle",
|
||
"field": "referenceImageSrc",
|
||
"message": "参考图过大,请压缩后重试。",
|
||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||
"actualBytes": bytes_len,
|
||
})),
|
||
);
|
||
}
|
||
return Ok(PuzzleResolvedReferenceImage {
|
||
mime_type: parsed.mime_type,
|
||
bytes_len,
|
||
bytes: parsed.bytes,
|
||
});
|
||
}
|
||
|
||
if !trimmed.starts_with('/') {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "puzzle",
|
||
"field": "referenceImageSrc",
|
||
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let object_key = trimmed.trim_start_matches('/');
|
||
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "puzzle",
|
||
"field": "referenceImageSrc",
|
||
"message": "参考图当前只支持 /generated-* 旧路径。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
let signed = oss_client
|
||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||
object_key: object_key.to_string(),
|
||
expire_seconds: Some(60),
|
||
})
|
||
.map_err(map_puzzle_asset_oss_error)?;
|
||
let response = http_client
|
||
.get(signed.signed_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/png")
|
||
.to_string();
|
||
let body = 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": "aliyun-oss",
|
||
"message": format!("读取参考图失败,状态码:{status}"),
|
||
"objectKey": object_key,
|
||
})),
|
||
);
|
||
}
|
||
if body.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": "读取参考图失败:对象内容为空",
|
||
"objectKey": object_key,
|
||
})),
|
||
);
|
||
}
|
||
|
||
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
|
||
let bytes_len = body.len();
|
||
Ok(PuzzleResolvedReferenceImage {
|
||
mime_type,
|
||
bytes_len,
|
||
bytes: body.to_vec(),
|
||
})
|
||
}
|
||
|
||
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: &AppState,
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
level_name: &str,
|
||
candidate_id: &str,
|
||
task_id: &str,
|
||
image: PuzzleDownloadedImage,
|
||
generated_at_micros: i64,
|
||
) -> Result<GeneratedPuzzleAssetResponse, AppError> {
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let asset_id = format!("asset-{generated_at_micros}");
|
||
let put_result = oss_client
|
||
.put_object(
|
||
&http_client,
|
||
OssPutObjectRequest {
|
||
prefix: LegacyAssetPrefix::PuzzleAssets,
|
||
path_segments: vec![
|
||
sanitize_path_segment(session_id, "session"),
|
||
sanitize_path_segment(level_name, "puzzle"),
|
||
sanitize_path_segment(candidate_id, "candidate"),
|
||
asset_id.clone(),
|
||
],
|
||
file_name: format!("image.{}", image.extension),
|
||
content_type: Some(image.mime_type.clone()),
|
||
access: OssObjectAccess::Private,
|
||
metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id),
|
||
body: image.bytes,
|
||
},
|
||
)
|
||
.await
|
||
.map_err(map_puzzle_asset_oss_error)?;
|
||
let head = oss_client
|
||
.head_object(
|
||
&http_client,
|
||
OssHeadObjectRequest {
|
||
object_key: put_result.object_key.clone(),
|
||
},
|
||
)
|
||
.await
|
||
.map_err(map_puzzle_asset_oss_error)?;
|
||
let asset_object = state
|
||
.spacetime_client()
|
||
.confirm_asset_object(
|
||
build_asset_object_upsert_input(
|
||
generate_asset_object_id(generated_at_micros),
|
||
head.bucket,
|
||
head.object_key,
|
||
AssetObjectAccessPolicy::Private,
|
||
head.content_type.or(Some(image.mime_type)),
|
||
head.content_length,
|
||
head.etag,
|
||
"puzzle_cover_image".to_string(),
|
||
Some(task_id.to_string()),
|
||
Some(owner_user_id.to_string()),
|
||
None,
|
||
Some(session_id.to_string()),
|
||
generated_at_micros,
|
||
)
|
||
.map_err(map_puzzle_asset_field_error)?,
|
||
)
|
||
.await;
|
||
match asset_object {
|
||
Ok(asset_object) => {
|
||
if let Err(error) = state
|
||
.spacetime_client()
|
||
.bind_asset_object_to_entity(
|
||
build_asset_entity_binding_input(
|
||
generate_asset_binding_id(generated_at_micros),
|
||
asset_object.asset_object_id,
|
||
PUZZLE_ENTITY_KIND.to_string(),
|
||
session_id.to_string(),
|
||
candidate_id.to_string(),
|
||
"puzzle_cover_image".to_string(),
|
||
Some(owner_user_id.to_string()),
|
||
None,
|
||
generated_at_micros,
|
||
)
|
||
.map_err(map_puzzle_asset_field_error)?,
|
||
)
|
||
.await
|
||
{
|
||
handle_puzzle_asset_spacetime_index_error(
|
||
error,
|
||
owner_user_id,
|
||
session_id,
|
||
candidate_id,
|
||
"绑定拼图资产对象到实体",
|
||
)?;
|
||
}
|
||
}
|
||
Err(error) => handle_puzzle_asset_spacetime_index_error(
|
||
error,
|
||
owner_user_id,
|
||
session_id,
|
||
candidate_id,
|
||
"确认拼图资产对象",
|
||
)?,
|
||
}
|
||
|
||
Ok(GeneratedPuzzleAssetResponse {
|
||
image_src: put_result.legacy_public_path,
|
||
asset_id,
|
||
})
|
||
}
|
||
|
||
pub(crate) async fn persist_puzzle_ui_background_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
level_name: &str,
|
||
task_id: &str,
|
||
image: DownloadedOpenAiImage,
|
||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let put_result = oss_client
|
||
.put_object(
|
||
&http_client,
|
||
OssPutObjectRequest {
|
||
prefix: LegacyAssetPrefix::PuzzleAssets,
|
||
path_segments: vec![
|
||
sanitize_path_segment(session_id, "session"),
|
||
sanitize_path_segment(level_name, "puzzle"),
|
||
"ui-background".to_string(),
|
||
sanitize_path_segment(task_id, "task"),
|
||
],
|
||
file_name: format!("background.{}", image.extension),
|
||
content_type: Some(image.mime_type.clone()),
|
||
access: OssObjectAccess::Private,
|
||
metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id),
|
||
body: image.bytes,
|
||
},
|
||
)
|
||
.await
|
||
.map_err(map_puzzle_asset_oss_error)?;
|
||
Ok(GeneratedPuzzleUiBackgroundResponse {
|
||
image_src: put_result.legacy_public_path,
|
||
object_key: put_result.object_key,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn handle_puzzle_asset_spacetime_index_error(
|
||
error: SpacetimeClientError,
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
candidate_id: &str,
|
||
stage: &str,
|
||
) -> Result<(), AppError> {
|
||
if should_skip_asset_operation_billing_for_connectivity(&error) {
|
||
// 中文注释:OSS 已经持有真实图片,资产索引的 SpacetimeDB 短暂失败只影响历史检索,不应阻断本次生图展示。
|
||
tracing::warn!(
|
||
provider = "spacetimedb",
|
||
owner_user_id,
|
||
session_id,
|
||
candidate_id,
|
||
stage,
|
||
error = %error,
|
||
"拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过"
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
Err(map_puzzle_asset_spacetime_error(error))
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_asset_metadata(
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
candidate_id: &str,
|
||
) -> BTreeMap<String, String> {
|
||
BTreeMap::from([
|
||
("asset_kind".to_string(), "puzzle_cover_image".to_string()),
|
||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
|
||
("entity_id".to_string(), session_id.to_string()),
|
||
("slot".to_string(), candidate_id.to_string()),
|
||
])
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_ui_background_asset_metadata(
|
||
owner_user_id: &str,
|
||
session_id: &str,
|
||
) -> BTreeMap<String, String> {
|
||
BTreeMap::from([
|
||
(
|
||
"asset_kind".to_string(),
|
||
"puzzle_ui_background_image".to_string(),
|
||
),
|
||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
|
||
("entity_id".to_string(), session_id.to_string()),
|
||
("slot".to_string(), "ui_background".to_string()),
|
||
])
|
||
}
|
||
|
||
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,")?;
|
||
if !mime_type.starts_with("image/") {
|
||
return None;
|
||
}
|
||
let bytes = decode_puzzle_base64(data)?;
|
||
Some(ParsedPuzzleImageDataUrl {
|
||
mime_type: mime_type.to_string(),
|
||
bytes,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||
let cleaned = value.trim().replace(char::is_whitespace, "");
|
||
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
|
||
let mut buffer = 0u32;
|
||
let mut bits = 0u8;
|
||
|
||
for byte in cleaned.bytes() {
|
||
let value = match byte {
|
||
b'A'..=b'Z' => byte - b'A',
|
||
b'a'..=b'z' => byte - b'a' + 26,
|
||
b'0'..=b'9' => byte - b'0' + 52,
|
||
b'+' => 62,
|
||
b'/' => 63,
|
||
b'=' => break,
|
||
_ => return None,
|
||
} as u32;
|
||
buffer = (buffer << 6) | value;
|
||
bits += 6;
|
||
while bits >= 8 {
|
||
bits -= 8;
|
||
output.push(((buffer >> bits) & 0xFF) as 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);
|
||
results.into_iter().next()
|
||
}
|
||
|
||
pub(crate) fn collect_puzzle_strings_by_key(
|
||
payload: &Value,
|
||
target_key: &str,
|
||
results: &mut Vec<String>,
|
||
) {
|
||
match payload {
|
||
Value::Array(entries) => {
|
||
for entry in entries {
|
||
collect_puzzle_strings_by_key(entry, target_key, results);
|
||
}
|
||
}
|
||
Value::Object(object) => {
|
||
for (key, value) in object {
|
||
if key == target_key {
|
||
collect_puzzle_string_values(value, results);
|
||
}
|
||
collect_puzzle_strings_by_key(value, target_key, results);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<String>) {
|
||
match payload {
|
||
Value::String(text) => results.push(text.to_string()),
|
||
Value::Array(items) => {
|
||
for item in items {
|
||
collect_puzzle_string_values(item, results);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
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(';')
|
||
.next()
|
||
.map(str::trim)
|
||
.unwrap_or("image/jpeg");
|
||
match mime_type {
|
||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||
mime_type.to_string()
|
||
}
|
||
_ => "image/jpeg".to_string(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn puzzle_mime_to_extension(mime_type: &str) -> &str {
|
||
match mime_type {
|
||
"image/png" => "png",
|
||
"image/webp" => "webp",
|
||
"image/gif" => "gif",
|
||
_ => "jpg",
|
||
}
|
||
}
|
||
|
||
pub(crate) fn map_puzzle_image_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": "puzzle-image",
|
||
"message": message,
|
||
"timeout": is_timeout,
|
||
}))
|
||
}
|
||
|
||
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 map_puzzle_vector_engine_reqwest_error(
|
||
context: &str,
|
||
request_url: &str,
|
||
error: reqwest::Error,
|
||
) -> AppError {
|
||
let message = format!(
|
||
"{context}:{}",
|
||
normalize_puzzle_reqwest_error_message(&error)
|
||
);
|
||
let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str());
|
||
let is_connect = error.is_connect();
|
||
let status = if is_timeout {
|
||
StatusCode::GATEWAY_TIMEOUT
|
||
} else {
|
||
StatusCode::BAD_GATEWAY
|
||
};
|
||
let source = error.source().map(ToString::to_string).unwrap_or_default();
|
||
|
||
tracing::warn!(
|
||
provider = VECTOR_ENGINE_PROVIDER,
|
||
endpoint = %request_url,
|
||
timeout = is_timeout,
|
||
connect = is_connect,
|
||
request = error.is_request(),
|
||
body = error.is_body(),
|
||
source = %source,
|
||
message = %message,
|
||
"拼图 VectorEngine 请求发送失败"
|
||
);
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": VECTOR_ENGINE_PROVIDER,
|
||
"message": message,
|
||
"reason": resolve_puzzle_vector_engine_request_failure_reason(&error),
|
||
"endpoint": request_url,
|
||
"timeout": is_timeout,
|
||
"connect": is_connect,
|
||
"request": error.is_request(),
|
||
"body": error.is_body(),
|
||
"source": source,
|
||
}))
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
|
||
error
|
||
.to_string()
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
}
|
||
|
||
pub(crate) fn resolve_puzzle_vector_engine_request_failure_reason(
|
||
error: &reqwest::Error,
|
||
) -> &'static str {
|
||
if error.is_timeout() {
|
||
return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
|
||
}
|
||
if error.is_connect() {
|
||
return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置";
|
||
}
|
||
if error.is_body() {
|
||
return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小";
|
||
}
|
||
"VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误"
|
||
}
|
||
|
||
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|
||
let lower = message.to_ascii_lowercase();
|
||
lower.contains("timed out")
|
||
|| lower.contains("timeout")
|
||
|| lower.contains("operation timed out")
|
||
|| 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")
|
||
}
|
||
|
||
pub(crate) fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
pub(crate) fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "asset-object",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
pub(crate) fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||
let sanitized = value
|
||
.trim()
|
||
.chars()
|
||
.map(|ch| {
|
||
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||
ch
|
||
} else {
|
||
'-'
|
||
}
|
||
})
|
||
.collect::<String>()
|
||
.trim_matches('-')
|
||
.to_string();
|
||
if sanitized.is_empty() {
|
||
fallback.to_string()
|
||
} else {
|
||
sanitized
|
||
}
|
||
}
|
||
|
||
pub(crate) fn current_utc_micros() -> i64 {
|
||
let duration = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default();
|
||
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
||
}
|