删除后端未使用的历史 helper、mapper、handler 和 re-export 将仅测试使用的导入、常量和辅助函数收口到 cfg(test) 补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约 验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试
1141 lines
37 KiB
Rust
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())
|
||
}
|