Files
Genarrative/server-rs/crates/api-server/src/puzzle/vector_engine.rs
T
kdletters decded991e 清理后端编译警告
删除后端未使用的历史 helper、mapper、handler 和 re-export

将仅测试使用的导入、常量和辅助函数收口到 cfg(test)

补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约

验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
2026-06-07 22:20:58 +08:00

1141 lines
37 KiB
Rust

use super::*;
use crate::openai_image_generation::{
OpenAiReferenceImage, create_openai_image_edit_with_references,
};
#[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 {
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) request_timeout_ms: u64,
pub(crate) external_api_audit_state: Option<AppState>,
pub(crate) external_api_audit_user_id: Option<String>,
pub(crate) external_api_audit_profile_id: Option<String>,
pub(crate) external_api_audit_request_id: Option<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) 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(),
external_api_audit_user_id: self.external_api_audit_user_id.clone(),
external_api_audit_profile_id: self.external_api_audit_profile_id.clone(),
external_api_audit_request_id: self.external_api_audit_request_id.clone(),
}
}
pub(crate) fn with_external_api_audit_context(
mut self,
request_context: &RequestContext,
user_id: Option<String>,
profile_id: Option<String>,
) -> Self {
self.external_api_audit_user_id = user_id;
self.external_api_audit_profile_id = profile_id;
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
self
}
}
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,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedPuzzleLevelAssetResponse {
pub(crate) image_src: String,
pub(crate) object_key: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedPuzzleLevelAssetBundle {
pub(crate) level_scene: GeneratedPuzzleLevelAssetResponse,
pub(crate) ui_spritesheet: GeneratedPuzzleLevelAssetResponse,
pub(crate) level_background: GeneratedPuzzleLevelAssetResponse,
}
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 = GPT_IMAGE_2_MODEL,
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2"
);
PuzzleImageModel::Gemini31FlashPreview
}
_ => PuzzleImageModel::GptImage2,
}
}
pub(crate) fn require_puzzle_vector_engine_settings(
state: &PuzzleApiState,
) -> Result<PuzzleVectorEngineSettings, AppError> {
let base_url = state.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
.vector_engine_api_key()
.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(),
request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1),
external_api_audit_state: Some(state.root_state().clone()),
external_api_audit_user_id: None,
external_api_audit_profile_id: None,
external_api_audit_request_id: None,
})
}
pub(crate) fn build_puzzle_image_http_client(
state: &PuzzleApiState,
_image_model: PuzzleImageModel,
) -> Result<reqwest::Client, AppError> {
let settings = require_puzzle_vector_engine_settings(state)?;
build_openai_image_http_client(&settings.to_openai_settings())
}
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> {
if let Some(reference_image) = reference_image {
return create_puzzle_vector_engine_image_edit(
http_client,
settings,
image_model,
prompt,
negative_prompt,
size,
candidate_count,
reference_image,
)
.await;
}
let generated = create_openai_image_generation(
http_client,
&settings.to_openai_settings(),
prompt,
Some(negative_prompt),
size,
candidate_count,
&[],
"拼图 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,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &PuzzleResolvedReferenceImage,
) -> Result<PuzzleGeneratedImages, AppError> {
let file_name = format!(
"puzzle-reference.{}",
puzzle_mime_to_extension(reference_image.mime_type.as_str())
);
let generated = create_openai_image_edit_with_references(
http_client,
&settings.to_openai_settings(),
prompt,
Some(negative_prompt),
size,
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(
image: &PuzzleDownloadedImage,
) -> PuzzleResolvedReferenceImage {
PuzzleResolvedReferenceImage {
mime_type: image.mime_type.clone(),
bytes_len: image.bytes.len(),
bytes: image.bytes.clone(),
}
}
#[cfg(test)]
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 body = serde_json::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())),
]);
let _ = reference_image;
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 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],
reference_image_asset_object_id: Option<&str>,
reference_image_asset_object_ids: &[String],
) -> Vec<String> {
let mut sources = Vec::new();
for source in reference_image_asset_object_id
.into_iter()
.chain(reference_image_asset_object_ids.iter().map(String::as_str))
.map(|asset_object_id| {
asset_object_id
.trim()
.strip_prefix("asset-object:")
.unwrap_or_else(|| asset_object_id.trim())
})
.filter(|asset_object_id| !asset_object_id.is_empty())
.map(|asset_object_id| format!("asset-object:{asset_object_id}"))
.chain(
legacy_reference_image_src
.into_iter()
.chain(reference_image_srcs.iter().map(String::as_str))
.map(str::to_string),
)
{
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],
reference_image_asset_object_id: Option<&str>,
reference_image_asset_object_ids: &[String],
) -> bool {
!collect_puzzle_reference_image_sources(
legacy_reference_image_src,
reference_image_srcs,
reference_image_asset_object_id,
reference_image_asset_object_ids,
)
.is_empty()
}
pub(crate) fn should_use_puzzle_reference_image_generation(
reference_image_src: Option<&str>,
use_reference_image_generation: bool,
) -> bool {
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
}
#[cfg(test)]
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 parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
source
.trim()
.strip_prefix("asset-object:")
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) async fn resolve_puzzle_reference_image(
state: &PuzzleApiState,
http_client: &reqwest::Client,
source: &str,
owner_user_id: Option<&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(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) {
return resolve_puzzle_reference_asset_object(
state,
http_client,
asset_object_id,
owner_user_id,
)
.await;
}
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": build_puzzle_reference_image_too_large_message(bytes_len),
"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": "参考图必须是 assetObjectId、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": "参考图当前只支持 assetObjectId 或 /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 signed_read_url = signed.signed_url;
download_signed_puzzle_reference_image(
http_client,
signed_read_url,
object_key,
None,
"referenceImageSrc",
)
.await
}
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
state: &PuzzleApiState,
http_client: &reqwest::Client,
source: &str,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
resolve_puzzle_reference_image(state, http_client, source, None).await
}
async fn resolve_puzzle_reference_asset_object(
state: &PuzzleApiState,
http_client: &reqwest::Client,
asset_object_id: &str,
owner_user_id: Option<&str>,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
let asset_object = state
.spacetime_client()
.get_asset_object(asset_object_id.to_string())
.await
.map_err(map_puzzle_client_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object_id,
"message": "参考图资产不存在或当前账号不可见。",
}))
})?;
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
validate_puzzle_reference_asset_object(
&asset_object,
owner_user_id,
oss_client.config_bucket(),
)?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: asset_object.object_key.clone(),
expire_seconds: Some(60),
})
.map_err(map_puzzle_asset_oss_error)?;
let content_type = asset_object.content_type.clone();
download_signed_puzzle_reference_image(
http_client,
signed.signed_url,
asset_object.object_key.as_str(),
content_type.as_deref(),
"referenceImageAssetObjectId",
)
.await
}
pub(crate) fn validate_puzzle_reference_asset_object(
asset_object: &module_assets::AssetObjectRecord,
owner_user_id: Option<&str>,
oss_bucket: &str,
) -> Result<(), AppError> {
if asset_object.bucket.trim() != oss_bucket.trim() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产 bucket 与当前服务 OSS 配置不一致。",
})),
);
}
if asset_object.asset_kind.trim() != "puzzle_cover_image" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产类型不属于拼图图片。",
})),
);
}
let content_type = asset_object
.content_type
.as_deref()
.map(str::trim)
.unwrap_or_default();
if !content_type.starts_with("image/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产不是图片类型。",
})),
);
}
if asset_object.content_length == 0
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": build_puzzle_reference_image_too_large_message(
asset_object.content_length as usize,
),
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": asset_object.content_length,
})),
);
}
if let Some(expected_owner_user_id) = owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
let actual_owner_user_id = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if actual_owner_user_id != Some(expected_owner_user_id) {
return Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产不属于当前账号。",
})),
);
}
}
Ok(())
}
async fn download_signed_puzzle_reference_image(
http_client: &reqwest::Client,
signed_read_url: String,
object_key: &str,
fallback_content_type: Option<&str>,
field: &str,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
let response = http_client
.get(signed_read_url.as_str())
.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())
.or(fallback_content_type)
.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,
"field": field,
})),
);
}
if body.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "读取参考图失败:对象内容为空",
"objectKey": object_key,
"field": field,
})),
);
}
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 persist_puzzle_generated_asset(
state: &PuzzleApiState,
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: &PuzzleApiState,
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) async fn persist_puzzle_level_asset_image(
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
task_id: &str,
path_segment: &str,
asset_kind: &str,
slot: &str,
file_stem: &str,
image: PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetResponse, 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"),
sanitize_path_segment(path_segment, "level-asset"),
sanitize_path_segment(task_id, "task"),
],
file_name: format!("{file_stem}.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_puzzle_level_asset_metadata(
owner_user_id,
session_id,
asset_kind,
slot,
),
body: image.bytes,
},
)
.await
.map_err(map_puzzle_asset_oss_error)?;
Ok(GeneratedPuzzleLevelAssetResponse {
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 build_puzzle_level_asset_metadata(
owner_user_id: &str,
session_id: &str,
asset_kind: &str,
slot: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), asset_kind.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(), slot.to_string()),
])
}
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 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 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_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())
}