1
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use module_big_fish::{BigFishAnchorPack, BigFishCreationStage};
|
||||
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value as JsonValue, json};
|
||||
@@ -226,40 +226,129 @@ fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue>
|
||||
fn parse_big_fish_model_output(
|
||||
parsed: &JsonValue,
|
||||
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
|
||||
serde_json::from_value::<BigFishAgentModelOutput>(parsed.clone())
|
||||
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼模型结果缺少必要内容,请稍后重试。"))
|
||||
let reply_text = parsed
|
||||
.get("replyText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少有效回复,请稍后重试。"))?
|
||||
.to_string();
|
||||
let progress_percent = parsed
|
||||
.get("progressPercent")
|
||||
.and_then(JsonValue::as_u64)
|
||||
.map(|value| value.min(100) as u32)
|
||||
.unwrap_or(0);
|
||||
let next_anchor_pack_value = parsed
|
||||
.get("nextAnchorPack")
|
||||
.cloned()
|
||||
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少 nextAnchorPack。"))?;
|
||||
let next_anchor_pack = parse_big_fish_model_anchor_pack(&next_anchor_pack_value)?;
|
||||
Ok(BigFishAgentModelOutput {
|
||||
reply_text,
|
||||
progress_percent,
|
||||
next_anchor_pack,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_big_fish_model_anchor_pack(
|
||||
value: &JsonValue,
|
||||
) -> Result<BigFishAnchorPack, BigFishAgentTurnError> {
|
||||
Ok(BigFishAnchorPack {
|
||||
// LLM 与 HTTP 契约使用 camelCase;SpacetimeDB 持久化结构保持 Rust snake_case,边界处必须显式翻译。
|
||||
gameplay_promise: parse_big_fish_model_anchor_item(value, "gameplayPromise")?,
|
||||
ecology_visual_theme: parse_big_fish_model_anchor_item(value, "ecologyVisualTheme")?,
|
||||
growth_ladder: parse_big_fish_model_anchor_item(value, "growthLadder")?,
|
||||
risk_tempo: parse_big_fish_model_anchor_item(value, "riskTempo")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_big_fish_model_anchor_item(
|
||||
pack: &JsonValue,
|
||||
field_name: &str,
|
||||
) -> Result<module_big_fish::BigFishAnchorItem, BigFishAgentTurnError> {
|
||||
let value = pack.get(field_name).ok_or_else(|| {
|
||||
BigFishAgentTurnError::new(format!("大鱼吃小鱼 anchor pack 缺少 {field_name}。"))
|
||||
})?;
|
||||
let key = value
|
||||
.get("key")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.unwrap_or(field_name)
|
||||
.to_string();
|
||||
let label = value
|
||||
.get("label")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.unwrap_or_else(|| default_big_fish_anchor_label(field_name))
|
||||
.to_string();
|
||||
let item_value = value
|
||||
.get("value")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let status = value
|
||||
.get("status")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(parse_big_fish_anchor_status)
|
||||
.unwrap_or(BigFishAnchorStatus::Missing);
|
||||
|
||||
Ok(module_big_fish::BigFishAnchorItem {
|
||||
key,
|
||||
label,
|
||||
value: item_value,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
|
||||
match field_name {
|
||||
"gameplayPromise" => "玩法承诺",
|
||||
"ecologyVisualTheme" => "生态视觉主题",
|
||||
"growthLadder" => "成长阶梯",
|
||||
"riskTempo" => "风险节奏",
|
||||
_ => "大鱼锚点",
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"gameplayPromise": {
|
||||
"key": anchor_pack.gameplay_promise.key,
|
||||
"label": anchor_pack.gameplay_promise.label,
|
||||
"value": anchor_pack.gameplay_promise.value,
|
||||
"status": anchor_pack.gameplay_promise.status,
|
||||
},
|
||||
"ecologyVisualTheme": {
|
||||
"key": anchor_pack.ecology_visual_theme.key,
|
||||
"label": anchor_pack.ecology_visual_theme.label,
|
||||
"value": anchor_pack.ecology_visual_theme.value,
|
||||
"status": anchor_pack.ecology_visual_theme.status,
|
||||
},
|
||||
"growthLadder": {
|
||||
"key": anchor_pack.growth_ladder.key,
|
||||
"label": anchor_pack.growth_ladder.label,
|
||||
"value": anchor_pack.growth_ladder.value,
|
||||
"status": anchor_pack.growth_ladder.status,
|
||||
},
|
||||
"riskTempo": {
|
||||
"key": anchor_pack.risk_tempo.key,
|
||||
"label": anchor_pack.risk_tempo.label,
|
||||
"value": anchor_pack.risk_tempo.value,
|
||||
"status": anchor_pack.risk_tempo.status,
|
||||
},
|
||||
}))
|
||||
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_pack(
|
||||
record: &spacetime_client::BigFishAnchorPackRecord,
|
||||
) -> BigFishAnchorPack {
|
||||
BigFishAnchorPack {
|
||||
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
|
||||
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
|
||||
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
|
||||
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_item(
|
||||
record: &spacetime_client::BigFishAnchorItemRecord,
|
||||
) -> module_big_fish::BigFishAnchorItem {
|
||||
module_big_fish::BigFishAnchorItem {
|
||||
key: record.key.clone(),
|
||||
label: record.label.clone(),
|
||||
value: record.value.clone(),
|
||||
status: parse_big_fish_anchor_status(record.status.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
|
||||
match value {
|
||||
"confirmed" => BigFishAnchorStatus::Confirmed,
|
||||
"locked" => BigFishAnchorStatus::Locked,
|
||||
"inferred" => BigFishAnchorStatus::Inferred,
|
||||
_ => BigFishAnchorStatus::Missing,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
|
||||
if let Ok(value) = serde_json::from_str::<JsonValue>(text) {
|
||||
return Ok(value);
|
||||
|
||||
@@ -394,19 +394,26 @@ impl AppConfig {
|
||||
config.oss_success_action_status = oss_success_action_status;
|
||||
}
|
||||
|
||||
if let Some(spacetime_server_url) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
|
||||
if let Some(spacetime_server_url) = read_first_non_empty_env(&[
|
||||
"GENARRATIVE_SPACETIME_SERVER_URL",
|
||||
"GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL",
|
||||
])
|
||||
{
|
||||
config.spacetime_server_url = spacetime_server_url;
|
||||
}
|
||||
|
||||
if let Some(spacetime_database) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"])
|
||||
if let Some(spacetime_database) = read_first_non_empty_env(&[
|
||||
"GENARRATIVE_SPACETIME_DATABASE",
|
||||
"GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE",
|
||||
])
|
||||
{
|
||||
config.spacetime_database = spacetime_database;
|
||||
}
|
||||
|
||||
config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]);
|
||||
config.spacetime_token = read_first_non_empty_env(&[
|
||||
"GENARRATIVE_SPACETIME_TOKEN",
|
||||
"GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN",
|
||||
]);
|
||||
if let Some(spacetime_pool_size) =
|
||||
read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"])
|
||||
{
|
||||
|
||||
@@ -38,7 +38,8 @@ use spacetime_client::{
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use std::{collections::BTreeSet, convert::Infallible, sync::Arc};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::info;
|
||||
|
||||
@@ -65,6 +66,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
|
||||
const DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS: usize = 2;
|
||||
|
||||
pub async fn get_custom_world_library(
|
||||
State(state): State<AppState>,
|
||||
@@ -1226,9 +1228,12 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
}
|
||||
};
|
||||
|
||||
let image_generation_limiter = Arc::new(Semaphore::new(
|
||||
DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS,
|
||||
));
|
||||
let role_visual_profile_input = draft_profile_value.clone();
|
||||
let act_background_profile_input = draft_profile_value.clone();
|
||||
// 角色主形象与幕背景图互不依赖,必须并行发起,避免底稿阶段串行等待两类图片。
|
||||
// 角色主形象与幕背景图互不依赖,必须并行发起;上游生图请求统一限流,避免同批草稿瞬时打满供应商接口。
|
||||
let (role_visual_result, act_background_result) = tokio::join!(
|
||||
async {
|
||||
let mut profile = role_visual_profile_input;
|
||||
@@ -1238,6 +1243,7 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
&mut profile,
|
||||
image_generation_limiter.clone(),
|
||||
)
|
||||
.await
|
||||
.map(|_| profile)
|
||||
@@ -1250,6 +1256,7 @@ fn spawn_custom_world_draft_foundation_job(
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
&mut profile,
|
||||
image_generation_limiter.clone(),
|
||||
)
|
||||
.await
|
||||
.map(|_| profile)
|
||||
@@ -1386,6 +1393,7 @@ async fn generate_draft_foundation_role_visuals(
|
||||
owner_user_id: &str,
|
||||
operation_id: &str,
|
||||
draft_profile: &mut Value,
|
||||
image_generation_limiter: Arc<Semaphore>,
|
||||
) -> Result<(), String> {
|
||||
let Some(profile_object) = draft_profile.as_object_mut() else {
|
||||
return Err("foundation draft JSON 必须是 object".to_string());
|
||||
@@ -1439,17 +1447,25 @@ async fn generate_draft_foundation_role_visuals(
|
||||
for role_ref in role_generation_refs {
|
||||
let task_state = (*state).clone();
|
||||
let task_owner_user_id = owner_user_id.to_string();
|
||||
let task_limiter = image_generation_limiter.clone();
|
||||
generation_tasks.spawn(async move {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
|
||||
match generate_character_primary_visual_for_profile(
|
||||
&task_state,
|
||||
task_owner_user_id.as_str(),
|
||||
role_ref.role_id.as_str(),
|
||||
role_ref.prompt.as_str(),
|
||||
Some(role_ref.name.as_str()),
|
||||
)
|
||||
.await
|
||||
let generation_result = {
|
||||
let _permit = task_limiter
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|error| format!("图片生成并发控制失效:{error}"))?;
|
||||
generate_character_primary_visual_for_profile(
|
||||
&task_state,
|
||||
task_owner_user_id.as_str(),
|
||||
role_ref.role_id.as_str(),
|
||||
role_ref.prompt.as_str(),
|
||||
Some(role_ref.name.as_str()),
|
||||
)
|
||||
.await
|
||||
};
|
||||
match generation_result
|
||||
{
|
||||
Ok(generated) => {
|
||||
return Ok::<_, String>((role_ref.key, role_ref.index, generated));
|
||||
@@ -1499,7 +1515,7 @@ async fn generate_draft_foundation_role_visuals(
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors.join(";"));
|
||||
return Err(join_unique_error_messages(errors));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1510,6 +1526,7 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
owner_user_id: &str,
|
||||
operation_id: &str,
|
||||
draft_profile: &mut Value,
|
||||
image_generation_limiter: Arc<Semaphore>,
|
||||
) -> Result<(), String> {
|
||||
let world_name =
|
||||
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
|
||||
@@ -1536,20 +1553,28 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
let task_owner_user_id = owner_user_id.to_string();
|
||||
let task_profile_id = profile_id.clone();
|
||||
let task_world_name = world_name.clone();
|
||||
let task_limiter = image_generation_limiter.clone();
|
||||
generation_tasks.spawn(async move {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
|
||||
match generate_custom_world_scene_image_for_profile(
|
||||
&task_state,
|
||||
task_owner_user_id.as_str(),
|
||||
task_profile_id.as_deref(),
|
||||
task_world_name.as_str(),
|
||||
act_ref.scene_id.as_str(),
|
||||
act_ref.title.as_str(),
|
||||
act_ref.summary.as_str(),
|
||||
act_ref.prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
let generation_result = {
|
||||
let _permit = task_limiter
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|error| format!("图片生成并发控制失效:{error}"))?;
|
||||
generate_custom_world_scene_image_for_profile(
|
||||
&task_state,
|
||||
task_owner_user_id.as_str(),
|
||||
task_profile_id.as_deref(),
|
||||
task_world_name.as_str(),
|
||||
act_ref.scene_id.as_str(),
|
||||
act_ref.title.as_str(),
|
||||
act_ref.summary.as_str(),
|
||||
act_ref.prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
match generation_result
|
||||
{
|
||||
Ok(generated) => {
|
||||
return Ok::<_, String>((
|
||||
@@ -1571,7 +1596,9 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"幕「{}」背景图连续生成 {} 次失败:{}",
|
||||
"第{}章第{}幕「{}」背景图连续生成 {} 次失败:{}",
|
||||
act_ref.chapter_index + 1,
|
||||
act_ref.act_index + 1,
|
||||
act_ref.title,
|
||||
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
|
||||
last_error.unwrap_or_else(|| "未知错误".to_string())
|
||||
@@ -1617,11 +1644,23 @@ async fn generate_draft_foundation_act_backgrounds(
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(errors.join(";"));
|
||||
return Err(join_unique_error_messages(errors));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn join_unique_error_messages(messages: Vec<String>) -> String {
|
||||
// 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|message| !message.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
struct RoleVisualGenerationRef {
|
||||
key: String,
|
||||
index: usize,
|
||||
|
||||
@@ -488,6 +488,7 @@ fn create_stable_id(prefix: &str, name: &str, index: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn character_expansion_prompt_keeps_node_contract_text() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
collections::BTreeMap,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -13,7 +12,13 @@ use axum::{
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::PuzzleGeneratedImageCandidate;
|
||||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
puzzle_agent::{
|
||||
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
||||
@@ -50,6 +55,7 @@ use spacetime_client::{
|
||||
SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::{
|
||||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||||
@@ -68,6 +74,10 @@ const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
||||
const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works";
|
||||
const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
||||
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
||||
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||
const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
@@ -451,29 +461,22 @@ pub async fn execute_puzzle_agent_action(
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
||||
let candidates = build_placeholder_puzzle_candidates(
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
candidate_count,
|
||||
)
|
||||
.await
|
||||
.map_err(SpacetimeClientError::Runtime);
|
||||
match candidates {
|
||||
Ok(candidates) => {
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
json!({
|
||||
"candidateId": candidate.candidate_id,
|
||||
"imageSrc": candidate.image_src,
|
||||
"assetId": candidate.asset_id,
|
||||
"prompt": candidate.prompt,
|
||||
"actualPrompt": candidate.actual_prompt,
|
||||
"sourceType": candidate.source_type,
|
||||
"selected": candidate.selected,
|
||||
})
|
||||
})
|
||||
.map(to_puzzle_generated_image_candidate)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
@@ -1429,27 +1432,47 @@ fn puzzle_sse_error_event_message(message: String) -> Event {
|
||||
Event::default().event("error").data(payload)
|
||||
}
|
||||
|
||||
fn build_placeholder_puzzle_candidates(
|
||||
async fn generate_puzzle_image_candidates(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
candidate_count: u32,
|
||||
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
||||
let count = candidate_count.clamp(1, 2);
|
||||
let mut items = Vec::with_capacity(count as usize);
|
||||
let settings =
|
||||
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
|
||||
let http_client = build_puzzle_dashscope_http_client(&settings)
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let generated = create_puzzle_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let mut items = Vec::with_capacity(generated.images.len());
|
||||
|
||||
for index in 0..count {
|
||||
let asset = save_placeholder_puzzle_asset(
|
||||
for (index, image) in generated.images.into_iter().enumerate() {
|
||||
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
|
||||
let asset = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
level_name,
|
||||
&format!("candidate-{}", index + 1),
|
||||
"cover",
|
||||
"1536*1536",
|
||||
Some(prompt),
|
||||
candidate_id.as_str(),
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
items.push(PuzzleGeneratedImageCandidateResponse {
|
||||
candidate_id: format!("{session_id}-candidate-{}", index + 1),
|
||||
candidate_id,
|
||||
image_src: asset.image_src,
|
||||
asset_id: asset.asset_id,
|
||||
prompt: prompt.to_string(),
|
||||
@@ -1473,98 +1496,467 @@ fn build_placeholder_puzzle_candidates(
|
||||
.collect())
|
||||
}
|
||||
|
||||
struct PuzzleDashScopeSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
struct PuzzleGeneratedImages {
|
||||
task_id: String,
|
||||
images: Vec<PuzzleDownloadedImage>,
|
||||
}
|
||||
|
||||
struct PuzzleDownloadedImage {
|
||||
extension: String,
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
struct GeneratedPuzzleAssetResponse {
|
||||
image_src: String,
|
||||
asset_id: String,
|
||||
}
|
||||
|
||||
fn save_placeholder_puzzle_asset(
|
||||
session_segment_seed: &str,
|
||||
work_segment_seed: &str,
|
||||
leaf_segment_seed: &str,
|
||||
file_stem: &str,
|
||||
fn require_puzzle_dashscope_settings(state: &AppState) -> Result<PuzzleDashScopeSettings, AppError> {
|
||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"reason": "DASHSCOPE_BASE_URL 未配置",
|
||||
})));
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.dashscope_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": "dashscope",
|
||||
"reason": "DASHSCOPE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(PuzzleDashScopeSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_dashscope_http_client(
|
||||
settings: &PuzzleDashScopeSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": format!("构造拼图 DashScope HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_puzzle_text_to_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleDashScopeSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
prompt: Option<&str>,
|
||||
candidate_count: u32,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let mut parameters = Map::from_iter([
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 2))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
("prompt_extend".to_string(), Value::Bool(true)),
|
||||
("watermark".to_string(), Value::Bool(false)),
|
||||
]);
|
||||
parameters.insert(
|
||||
"negative_prompt".to_string(),
|
||||
Value::String(negative_prompt.to_string()),
|
||||
);
|
||||
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}/services/aigc/text2image/image-synthesis",
|
||||
settings.base_url
|
||||
))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header("X-DashScope-Async", "enable")
|
||||
.json(&json!({
|
||||
"model": PUZZLE_TEXT_TO_IMAGE_MODEL,
|
||||
"input": { "prompt": prompt },
|
||||
"parameters": parameters,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
response_text.as_str(),
|
||||
"创建拼图图片生成任务失败",
|
||||
));
|
||||
}
|
||||
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图图片生成响应失败")?;
|
||||
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成任务未返回 task_id",
|
||||
}))
|
||||
})?;
|
||||
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let poll_response = http_client
|
||||
.get(format!("{}/tasks/{}", settings.base_url, task_id))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
|
||||
})?;
|
||||
let poll_status = poll_response.status();
|
||||
let poll_text = poll_response.text().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
|
||||
})?;
|
||||
if !poll_status.is_success() {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
"查询拼图图片生成任务失败",
|
||||
));
|
||||
}
|
||||
let poll_payload = parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
|
||||
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if task_status == "SUCCEEDED" {
|
||||
let image_urls = extract_puzzle_image_urls(&poll_payload);
|
||||
if image_urls.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成成功但未返回图片地址",
|
||||
})));
|
||||
}
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls.into_iter().take(candidate_count.clamp(1, 2) as usize) {
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
return Ok(PuzzleGeneratedImages { task_id, images });
|
||||
}
|
||||
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
"拼图图片生成任务失败",
|
||||
));
|
||||
}
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成超时或未返回图片地址",
|
||||
})))
|
||||
}
|
||||
|
||||
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_dashscope_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_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"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(),
|
||||
})
|
||||
}
|
||||
|
||||
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 asset_id = format!("{file_stem}-{}", current_utc_millis());
|
||||
let relative_dir = PathBuf::from("generated-puzzle-covers")
|
||||
.join(sanitize_path_segment(session_segment_seed, "session"))
|
||||
.join(sanitize_path_segment(work_segment_seed, "puzzle"))
|
||||
.join(sanitize_path_segment(leaf_segment_seed, "candidate"))
|
||||
.join(&asset_id);
|
||||
let output_dir = resolve_public_output_dir(&relative_dir)?;
|
||||
fs::create_dir_all(&output_dir).map_err(io_error)?;
|
||||
let file_name = format!("{file_stem}.svg");
|
||||
let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem));
|
||||
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
|
||||
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
|
||||
.map_err(map_puzzle_asset_spacetime_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
|
||||
.map_err(map_puzzle_asset_spacetime_error)?;
|
||||
|
||||
Ok(GeneratedPuzzleAssetResponse {
|
||||
image_src: format!(
|
||||
"/{}/{}",
|
||||
relative_dir.to_string_lossy().replace('\\', "/"),
|
||||
file_name
|
||||
),
|
||||
image_src: put_result.legacy_public_path,
|
||||
asset_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#201a0f"/>
|
||||
<stop offset="50%" stop-color="#4a2c24"/>
|
||||
<stop offset="100%" stop-color="#10243a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,244,214,0.12)"/>
|
||||
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(115,194,255,0.14)"/>
|
||||
<rect x="{frame_x}" y="{frame_y}" width="{frame_w}" height="{frame_h}" rx="{frame_r}" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.18)"/>
|
||||
<text x="50%" y="47%" text-anchor="middle" fill="#f6e9cf" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="57%" text-anchor="middle" fill="#d4e8ff" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Puzzle placeholder</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
cx1 = width / 4,
|
||||
cy1 = height / 3,
|
||||
r1 = (width.min(height) / 5).max(42),
|
||||
cx2 = width * 3 / 4,
|
||||
cy2 = height / 4,
|
||||
r2 = (width.min(height) / 7).max(30),
|
||||
frame_x = width / 9,
|
||||
frame_y = height / 9,
|
||||
frame_w = width * 7 / 9,
|
||||
frame_h = height * 7 / 9,
|
||||
frame_r = (width.min(height) / 20).max(18),
|
||||
font_main = (width.min(height) / 14).max(22),
|
||||
font_sub = (width.min(height) / 30).max(12),
|
||||
title = escape_svg_text(label),
|
||||
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_size(size: &str) -> (u32, u32) {
|
||||
let mut parts = size.split('*');
|
||||
let width = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1536);
|
||||
let height = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1536);
|
||||
(width, height)
|
||||
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()),
|
||||
])
|
||||
}
|
||||
|
||||
fn escape_svg_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
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": "dashscope",
|
||||
"message": format!("{fallback_message}:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
|
||||
find_first_puzzle_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
&& let Some(text) = value.as_str()
|
||||
{
|
||||
results.push(text.to_string());
|
||||
}
|
||||
collect_puzzle_strings_by_key(value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn puzzle_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => "jpg",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_dashscope_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_puzzle_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": parse_puzzle_api_error_message(raw_text, fallback_message),
|
||||
}))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||||
@@ -1588,31 +1980,9 @@ fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message("无法定位仓库根目录")
|
||||
})?;
|
||||
Ok(workspace_root.join("public").join(relative_dir))
|
||||
}
|
||||
|
||||
fn io_error(error: std::io::Error) -> AppError {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn current_utc_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user