Files
Genarrative/server-rs/crates/api-server/src/custom_world_ai.rs

2603 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, GenericImageView, imageops::FilterType};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use spacetime_client::SpacetimeClientError;
use tokio::time::sleep;
use webp::Encoder as WebpEncoder;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldEntityRequest {
profile: Value,
kind: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldSceneNpcRequest {
profile: Value,
landmark_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldSceneImageRequest {
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
world_name: Option<String>,
#[serde(default)]
landmark_id: Option<String>,
#[serde(default)]
landmark_name: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
size: Option<String>,
#[serde(default)]
negative_prompt: Option<String>,
#[serde(default)]
reference_image_src: Option<String>,
#[serde(default)]
user_prompt: Option<String>,
#[serde(default)]
profile: Option<SceneImageProfileInput>,
#[serde(default)]
landmark: Option<SceneImageLandmarkInput>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldCoverImageRequest {
profile: CoverProfileInput,
#[serde(default)]
user_prompt: Option<String>,
#[serde(default)]
reference_image_src: Option<String>,
#[serde(default)]
character_role_ids: Vec<String>,
#[serde(default)]
size: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldCoverUploadRequest {
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
world_name: Option<String>,
image_data_url: String,
crop_rect: CustomWorldCoverCropRect,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeneratedAssetResponse {
image_src: String,
asset_id: String,
source_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
size: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
actual_prompt: Option<String>,
}
struct PreparedAssetUpload {
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
asset_kind: &'static str,
entity_kind: &'static str,
entity_id: String,
profile_id: Option<String>,
slot: &'static str,
source_job_id: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SceneImageProfileInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
tone: Option<String>,
#[serde(default)]
player_goal: Option<String>,
#[serde(default)]
setting_text: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SceneImageLandmarkInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
danger_level: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverRoleInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverCampInput {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverLandmarkInput {
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverActInput {
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
background_image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverSceneChapterInput {
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
#[serde(default)]
#[allow(dead_code)]
scene_id: Option<String>,
#[serde(default)]
#[allow(dead_code)]
title: Option<String>,
#[serde(default)]
#[allow(dead_code)]
summary: Option<String>,
#[serde(default)]
acts: Vec<CoverActInput>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverProfileInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
tone: Option<String>,
#[serde(default)]
player_goal: Option<String>,
#[serde(default)]
setting_text: Option<String>,
#[serde(default)]
camp: Option<CoverCampInput>,
#[serde(default)]
landmarks: Vec<CoverLandmarkInput>,
#[serde(default)]
playable_npcs: Vec<CoverRoleInput>,
#[serde(default)]
story_npcs: Vec<CoverRoleInput>,
#[serde(default)]
scene_chapter_blueprints: Vec<CoverSceneChapterInput>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CustomWorldCoverCropRect {
x: f64,
y: f64,
width: f64,
height: f64,
}
struct DashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct DashScopeGeneratedImage {
image_url: String,
task_id: String,
actual_prompt: Option<String>,
}
struct DownloadedRemoteImage {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
struct CoverPromptContext {
opening_act_title: String,
opening_act_summary: String,
role_summary: String,
story_role_summary: String,
landmark_summary: String,
}
struct NormalizedSceneImageRequest {
profile_id: Option<String>,
world_name: String,
entity_id: String,
size: String,
prompt: String,
negative_prompt: String,
reference_image_src: Option<String>,
model: String,
}
#[derive(Debug)]
struct NormalizedCropRect {
left: u32,
top: u32,
width: u32,
height: u32,
}
#[derive(Debug)]
struct OptimizedCoverUpload {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
const TEXT_TO_IMAGE_SCENE_MODEL: &str = "wan2.2-t2i-flash";
const REFERENCE_IMAGE_SCENE_MODEL: &str = "qwen-image-2.0";
const TEXT_TO_IMAGE_COVER_MODEL: &str = "wan2.2-t2i-flash";
const REFERENCE_IMAGE_COVER_MODEL: &str = "qwen-image-2.0";
const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头";
const COVER_OUTPUT_WIDTH: u32 = 1600;
const COVER_OUTPUT_HEIGHT: u32 = 900;
const COVER_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
const COVER_OUTPUT_MAX_BYTES: usize = (1.5 * 1024.0 * 1024.0) as usize;
const COVER_MIN_RATIO: f64 = 1.7;
const COVER_MAX_RATIO: f64 = 1.8;
pub async fn generate_custom_world_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldEntityRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let kind = payload.kind.trim();
if !matches!(kind, "playable" | "story" | "landmark") {
return Err(custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "kind 必须是 playable、story 或 landmark",
})),
));
}
let entity = generate_entity_with_fallback(&state, &payload.profile, kind).await;
Ok(json_success_body(
Some(&request_context),
json!({
"kind": kind,
"entity": entity,
}),
))
}
pub async fn generate_custom_world_scene_npc(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneNpcRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let landmark_id = payload.landmark_id.trim();
if landmark_id.is_empty() {
return Err(custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "landmarkId is required",
})),
));
}
let npc = generate_scene_npc_with_fallback(&state, &payload.profile, landmark_id).await;
Ok(json_success_body(
Some(&request_context),
json!({ "npc": npc }),
))
}
pub async fn generate_custom_world_scene_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_scene_image_request(payload)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let settings = require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let http_client = build_dashscope_http_client(&settings)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
Some(
resolve_reference_image_as_data_url(
&state,
&http_client,
reference_image_src,
"referenceImageSrc",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?,
)
} else {
None
};
let generated = if let Some(reference_image) = reference_image.as_deref() {
create_reference_image_generation(
&http_client,
&settings,
REFERENCE_IMAGE_SCENE_MODEL,
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
Some(normalized.negative_prompt.as_str()),
"创建参考图场景编辑任务失败",
"参考图场景编辑未返回图片地址",
"scene-edit",
)
.await
} else {
create_text_to_image_generation(
&http_client,
&settings,
TEXT_TO_IMAGE_SCENE_MODEL,
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await
}
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: format!("scene.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id: normalized.entity_id.clone(),
profile_id: normalized.profile_id.clone(),
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(normalized.model),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn generate_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile.id.as_deref());
let world_name =
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
let settings = require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let http_client = build_dashscope_http_client(&settings)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let reference_sources = collect_cover_reference_image_sources(
&payload.profile,
&payload.character_role_ids,
payload.reference_image_src.as_deref().unwrap_or_default(),
);
let prompt = build_custom_world_cover_image_prompt(
&payload.profile,
&payload.character_role_ids,
payload.user_prompt.as_deref().unwrap_or_default(),
!reference_sources.is_empty(),
);
let mut reference_images = Vec::with_capacity(reference_sources.len());
for source in &reference_sources {
reference_images.push(
resolve_reference_image_as_data_url(
&state,
&http_client,
source.as_str(),
"referenceImageSrc",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?,
);
}
let generated = if reference_images.is_empty() {
create_text_to_image_generation(
&http_client,
&settings,
TEXT_TO_IMAGE_COVER_MODEL,
prompt.as_str(),
None,
size.as_str(),
"创建作品封面生成任务失败",
"查询作品封面任务失败",
"作品封面生成任务失败",
"作品封面生成超时或未返回图片地址",
)
.await
} else {
create_reference_image_generation(
&http_client,
&settings,
REFERENCE_IMAGE_COVER_MODEL,
prompt.as_str(),
size.as_str(),
&reference_images,
None,
"创建参考图封面任务失败",
"封面生成未返回图片地址",
"cover-edit",
)
.await
}
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载作品封面失败",
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-cover-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: format!("cover.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(if reference_images.is_empty() {
TEXT_TO_IMAGE_COVER_MODEL.to_string()
} else {
REFERENCE_IMAGE_COVER_MODEL.to_string()
}),
size: Some(size),
task_id: Some(generated.task_id),
prompt: Some(prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn upload_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverUploadRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": error.body_text(),
})),
)
})?;
let parsed = parse_image_data_url(payload.image_data_url.trim()).ok_or_else(|| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "imageDataUrl 必须是有效的图片 Data URL",
})),
)
})?;
let optimized = optimize_uploaded_cover_image(&parsed, &payload.crop_rect)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile_id.as_deref());
let world_name =
trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let asset_id = format!("custom-cover-upload-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: format!("cover.{}", optimized.extension),
content_type: optimized.mime_type,
body: optimized.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(asset_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id,
source_type: "uploaded".to_string(),
model: None,
size: None,
task_id: None,
prompt: None,
actual_prompt: None,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,
upload: PreparedAssetUpload,
mut response: GeneratedAssetResponse,
) -> Result<GeneratedAssetResponse, 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: upload.prefix,
path_segments: upload.path_segments,
file_name: upload.file_name,
content_type: Some(upload.content_type.clone()),
access: OssObjectAccess::Private,
metadata: build_asset_metadata(
upload.asset_kind,
owner_user_id,
upload.profile_id.as_deref(),
upload.entity_kind,
upload.entity_id.as_str(),
upload.slot,
),
body: upload.body,
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
// custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(upload.content_type)),
head.content_length,
head.etag,
upload.asset_kind.to_string(),
upload.source_job_id,
Some(owner_user_id.to_string()),
upload.profile_id.clone(),
Some(upload.entity_id.clone()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
upload.entity_kind.to_string(),
upload.entity_id,
upload.slot.to_string(),
upload.asset_kind.to_string(),
Some(owner_user_id.to_string()),
upload.profile_id,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
response.image_src = put_result.legacy_public_path;
Ok(response)
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
profile_id: Option<&str>,
entity_kind: &str,
entity_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), entity_kind.to_string()),
("entity_id".to_string(), entity_id.to_string()),
("slot".to_string(), slot.to_string()),
]);
if let Some(profile_id) = profile_id {
metadata.insert("profile_id".to_string(), profile_id.to_string());
}
metadata
}
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
let status = match error {
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
StatusCode::BAD_REQUEST
}
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
platform_oss::OssError::Request(_)
| platform_oss::OssError::SerializePolicy(_)
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
let fallback = build_entity_fallback(profile, kind);
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。",
),
LlmMessage::user(
json!({
"task": "generate_custom_world_entity",
"kind": kind,
"profile": profile,
"fallback": fallback,
})
.to_string(),
),
]);
llm_client
.request_text(request)
.await
.ok()
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
.unwrap_or(fallback)
}
async fn generate_scene_npc_with_fallback(
state: &AppState,
profile: &Value,
landmark_id: &str,
) -> Value {
let fallback = build_scene_npc_fallback(profile, landmark_id);
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。",
),
LlmMessage::user(
json!({
"task": "generate_custom_world_scene_npc",
"landmarkId": landmark_id,
"profile": profile,
"fallback": fallback,
})
.to_string(),
),
]);
llm_client
.request_text(request)
.await
.ok()
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
.unwrap_or(fallback)
}
fn build_entity_fallback(profile: &Value, kind: &str) -> Value {
let object = profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
match kind {
"playable" => build_role_fallback("playable", "新同行者", &world_name, 18),
"story" => build_role_fallback("story", "新场景角色", &world_name, 6),
"landmark" => build_landmark_fallback(&world_name),
_ => json!({}),
}
}
fn build_scene_npc_fallback(profile: &Value, landmark_id: &str) -> Value {
let object = profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
let landmark_name = object
.get("landmarks")
.and_then(Value::as_array)
.and_then(|entries| {
entries.iter().find_map(|entry| {
let object = entry.as_object()?;
(read_string_field(object, "id").as_deref() == Some(landmark_id))
.then(|| read_string_field(object, "name"))
.flatten()
})
})
.unwrap_or_else(|| "当前场景".to_string());
let mut npc = build_role_fallback("story", &format!("{landmark_name}来客"), &world_name, 6);
if let Some(object) = npc.as_object_mut() {
object.insert(
"description".to_string(),
Value::String(format!("长期活动于{landmark_name},熟悉这里的局势与暗线。")),
);
}
npc
}
fn build_role_fallback(prefix: &str, name: &str, world_name: &str, affinity: i64) -> Value {
let suffix = current_utc_millis();
json!({
"id": format!("{prefix}-{}", suffix),
"name": name,
"title": "关键角色",
"role": "关键角色",
"description": format!("围绕《{world_name}》当前主线冲突生成的新增角色。"),
"backstory": format!("他与《{world_name}》正在展开的局势存在直接牵连。"),
"personality": "谨慎、敏锐,先观察再表态。",
"motivation": "希望借玩家的介入改变当前失衡局面。",
"combatStyle": "偏向试探与控场。",
"initialAffinity": affinity,
"relationshipHooks": ["与玩家保持试探", "掌握局势暗线"],
"relations": [],
"tags": ["自定义", "生成"],
"backstoryReveal": {
"publicSummary": "一个掌握部分旧线索的关键角色。",
"chapters": [
{ "id": "surface", "title": "表层来意", "affinityRequired": 6, "teaser": "他知道这里正在发生什么。", "content": "他一直在观察这片区域的变化。", "contextSnippet": "" },
{ "id": "scar", "title": "旧事裂痕", "affinityRequired": 12, "teaser": "他与旧案有直接关联。", "content": "过往的一次事件把他绑定在这条线里。", "contextSnippet": "" },
{ "id": "hidden", "title": "隐藏执念", "affinityRequired": 18, "teaser": "他真正想推动的局面还没说出口。", "content": "他一直在寻找能撬动局面的机会。", "contextSnippet": "" },
{ "id": "final", "title": "最终底牌", "affinityRequired": 24, "teaser": "他手里还压着一张底牌。", "content": "一旦局势逼近临界点,他会出手。", "contextSnippet": "" }
]
},
"skills": [
{ "id": format!("skill-{}-1", suffix), "name": "试探起手", "summary": "先判断局势与对手意图。", "style": "试探压制" },
{ "id": format!("skill-{}-2", suffix), "name": "借势压场", "summary": "利用环境为自己制造主动权。", "style": "环境协同" },
{ "id": format!("skill-{}-3", suffix), "name": "暗线反制", "summary": "在关键节点打乱对方节奏。", "style": "后手翻盘" }
],
"initialItems": [
{ "id": format!("item-{}-1", suffix), "name": "随身兵装", "category": "武器", "quantity": 1, "rarity": "rare", "description": "常备的近身装备。", "tags": ["自定义"] },
{ "id": format!("item-{}-2", suffix), "name": "私人物件", "category": "道具", "quantity": 1, "rarity": "uncommon", "description": "可在关键时刻调用的人情或凭证。", "tags": ["自定义"] },
{ "id": format!("item-{}-3", suffix), "name": "线索残页", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "记录部分隐藏线索。", "tags": ["线索"] }
]
})
}
fn build_landmark_fallback(world_name: &str) -> Value {
let suffix = current_utc_millis();
json!({
"id": format!("landmark-{}", suffix),
"name": "新场景",
"description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"),
"visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。",
"dangerLevel": "medium",
"sceneNpcIds": [],
"connections": [],
"narrativeResidues": [],
})
}
fn normalize_scene_image_request(
payload: CustomWorldSceneImageRequest,
) -> Result<NormalizedSceneImageRequest, AppError> {
let profile = payload.profile.unwrap_or_default();
let landmark = payload.landmark.unwrap_or_default();
let reference_image_src = trim_to_option(payload.reference_image_src.as_deref());
let profile_id = trim_to_option(payload.profile_id.as_deref())
.or_else(|| trim_to_option(profile.id.as_deref()));
let world_name = trim_to_option(payload.world_name.as_deref())
.or_else(|| trim_to_option(profile.name.as_deref()))
.unwrap_or_else(|| "world".to_string());
let landmark_id = trim_to_option(payload.landmark_id.as_deref())
.or_else(|| trim_to_option(landmark.id.as_deref()));
let landmark_name = trim_to_option(payload.landmark_name.as_deref())
.or_else(|| trim_to_option(landmark.name.as_deref()));
if landmark_id.is_none() && landmark_name.is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "landmarkName 或 landmarkId 至少要提供一个",
})),
);
}
let prompt = trim_to_option(payload.prompt.as_deref()).unwrap_or_else(|| {
build_custom_world_scene_image_prompt(
&profile,
&landmark,
payload.user_prompt.as_deref().unwrap_or_default(),
reference_image_src.is_some(),
landmark_name.as_deref(),
world_name.as_str(),
)
});
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "prompt 不能为空",
})),
);
}
Ok(NormalizedSceneImageRequest {
profile_id,
world_name,
entity_id: landmark_id
.or(landmark_name)
.unwrap_or_else(|| "scene".to_string()),
size: trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1280*720".to_string()),
prompt,
negative_prompt: trim_to_option(payload.negative_prompt.as_deref())
.unwrap_or_else(|| DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT.to_string()),
reference_image_src: reference_image_src.clone(),
model: if reference_image_src.is_some() {
REFERENCE_IMAGE_SCENE_MODEL.to_string()
} else {
TEXT_TO_IMAGE_SCENE_MODEL.to_string()
},
})
}
fn build_custom_world_scene_image_prompt(
profile: &SceneImageProfileInput,
landmark: &SceneImageLandmarkInput,
user_prompt: &str,
has_reference_image: bool,
fallback_landmark_name: Option<&str>,
fallback_world_name: &str,
) -> String {
let world_name = clamp_scene_image_text(
trim_to_option(profile.name.as_deref())
.unwrap_or_else(|| fallback_world_name.to_string())
.as_str(),
18,
);
let world_subtitle = clamp_scene_image_text(
trim_to_option(profile.subtitle.as_deref())
.unwrap_or_default()
.as_str(),
18,
);
let world_tone = clamp_scene_image_text(
trim_to_option(profile.tone.as_deref())
.unwrap_or_default()
.as_str(),
48,
);
let world_goal = clamp_scene_image_text(
trim_to_option(profile.player_goal.as_deref())
.unwrap_or_default()
.as_str(),
48,
);
let world_summary = clamp_scene_image_text(
trim_to_option(profile.summary.as_deref())
.unwrap_or_default()
.as_str(),
72,
);
let world_setting = clamp_scene_image_text(
trim_to_option(profile.setting_text.as_deref())
.unwrap_or_default()
.as_str(),
72,
);
let landmark_name = clamp_scene_image_text(
trim_to_option(landmark.name.as_deref())
.or_else(|| fallback_landmark_name.map(ToOwned::to_owned))
.unwrap_or_else(|| "未命名场景".to_string())
.as_str(),
18,
);
let landmark_description = clamp_scene_image_text(
trim_to_option(landmark.description.as_deref())
.unwrap_or_default()
.as_str(),
96,
);
let requested_visual = clamp_scene_image_text(user_prompt, 120);
let danger_mood = describe_danger_level(
trim_to_option(landmark.danger_level.as_deref())
.unwrap_or_default()
.as_str(),
);
vec![
"为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(),
"画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(),
"下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(),
"下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(),
"下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(),
if has_reference_image {
"已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string()
} else {
String::new()
},
format!(
"世界:{}{}",
if world_name.is_empty() {
"未命名世界"
} else {
world_name.as_str()
},
if world_subtitle.is_empty() {
String::new()
} else {
format!("{world_subtitle}")
}
),
conditional_prompt_line("玩家设定", world_setting.as_str()),
conditional_prompt_line("世界概述", world_summary.as_str()),
conditional_prompt_line("整体基调", world_tone.as_str()),
conditional_prompt_line("玩家目标关联", world_goal.as_str()),
format!(
"场景名称:{}",
if landmark_name.is_empty() {
"未命名场景"
} else {
landmark_name.as_str()
}
),
conditional_prompt_line("场景描述", landmark_description.as_str()),
conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()),
format!("{danger_mood}"),
"不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(),
]
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("")
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前。
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(DashScopeSettings {
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_dashscope_http_client(settings: &DashScopeSettings) -> 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}"),
}))
})
}
async fn resolve_reference_image_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
field: &str,
) -> Result<String, AppError> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"field": field,
"message": "参考图不能为空。",
})),
);
}
if let Some(parsed) = parse_image_data_url(trimmed) {
return Ok(format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(parsed.bytes)
));
}
if !trimmed.starts_with('/') {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"field": field,
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
})),
);
}
let object_key = trimmed.trim_start_matches('/');
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"field": field,
"message": "参考图当前只支持 /generated-* 旧路径。",
})),
);
}
// Rust 端不再回读仓库 public 目录,只兼容 Data URL 和现有 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_custom_world_asset_oss_error)?;
let response = http_client
.get(signed.signed_url)
.send()
.await
.map_err(|error| map_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/png")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_dashscope_request_error(format!("读取参考图内容失败:{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取参考图失败,状态码:{status}"),
"objectKey": object_key,
})),
);
}
if body.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "读取参考图失败:对象内容为空",
"objectKey": object_key,
})),
);
}
Ok(format!(
"data:{};base64,{}",
content_type,
BASE64_STANDARD.encode(body)
))
}
async fn create_text_to_image_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
create_error_message: &str,
poll_error_message: &str,
failed_error_message: &str,
timeout_error_message: &str,
) -> Result<DashScopeGeneratedImage, AppError> {
let mut parameters = Map::from_iter([
("n".to_string(), json!(1)),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
if let Some(negative_prompt) = negative_prompt
&& !negative_prompt.trim().is_empty()
{
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
// 文生图链路保持和 Node 旧实现一致:先异步创建任务,再轮询 task 状态。
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": model,
"input": {
"prompt": prompt,
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
let response_status = response.status();
let response_text = response
.text()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
create_error_message,
));
}
let response_json = parse_json_payload(response_text.as_str(), create_error_message)?;
let task_id = extract_task_id(&response_json.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_dashscope_request_error(format!("{poll_error_message}{error}"))
})?;
let poll_status_code = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_dashscope_request_error(format!("{poll_error_message}{error}"))
})?;
if !poll_status_code.is_success() {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
poll_error_message,
));
}
let poll_json = parse_json_payload(poll_text.as_str(), poll_error_message)?;
let task_status = find_first_string_by_key(&poll_json.payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_url = extract_image_urls(&poll_json.payload)
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": timeout_error_message,
}))
})?;
return Ok(DashScopeGeneratedImage {
image_url,
task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
failed_error_message,
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": timeout_error_message,
})),
)
}
async fn create_reference_image_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
prompt: &str,
size: &str,
reference_images: &[String],
negative_prompt: Option<&str>,
create_error_message: &str,
empty_image_error_message: &str,
task_prefix: &str,
) -> Result<DashScopeGeneratedImage, AppError> {
let mut content = reference_images
.iter()
.map(|image| json!({ "image": image }))
.collect::<Vec<_>>();
content.push(json!({ "text": prompt }));
let mut parameters = Map::from_iter([
("n".to_string(), json!(1)),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
if let Some(negative_prompt) = negative_prompt
&& !negative_prompt.trim().is_empty()
{
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
let response = http_client
.post(format!(
"{}/services/aigc/multimodal-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": content,
}
]
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
let response_status = response.status();
let response_text = response
.text()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
create_error_message,
));
}
let response_json = parse_json_payload(response_text.as_str(), create_error_message)?;
let image_url = extract_image_urls(&response_json.payload)
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": empty_image_error_message,
}))
})?;
Ok(DashScopeGeneratedImage {
image_url,
task_id: format!("{task_prefix}-{}", current_utc_millis()),
actual_prompt: find_first_string_by_key(&response_json.payload, "actual_prompt"),
})
}
async fn download_remote_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteImage, AppError> {
let response = http_client
.get(image_url)
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{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_dashscope_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedRemoteImage {
extension: mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: bytes.to_vec(),
})
}
fn optimize_uploaded_cover_image(
parsed_data_url: &ParsedImageDataUrl,
crop_rect: &CustomWorldCoverCropRect,
) -> Result<OptimizedCoverUpload, AppError> {
if parsed_data_url.bytes.len() > COVER_UPLOAD_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面原图不能超过 10 MB。",
})),
);
}
let image = image::load_from_memory(parsed_data_url.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": format!("无法解析上传封面:{error}"),
}))
})?;
let (source_width, source_height) = image.dimensions();
if source_width == 0 || source_height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "无法解析上传封面的尺寸。",
})),
);
}
let normalized_crop = normalize_cover_crop_rect(source_width, source_height, crop_rect)?;
let resized = image
.crop_imm(
normalized_crop.left,
normalized_crop.top,
normalized_crop.width,
normalized_crop.height,
)
.resize_exact(
COVER_OUTPUT_WIDTH,
COVER_OUTPUT_HEIGHT,
FilterType::CatmullRom,
);
// 上传封面固定产出 1600x900 WebP并按质量档位递减直到满足体积约束。
let mut encoded = encode_dynamic_image_to_webp(&resized, 90.0)?;
for quality in [84.0, 76.0, 68.0, 60.0] {
if encoded.len() <= COVER_OUTPUT_MAX_BYTES {
break;
}
encoded = encode_dynamic_image_to_webp(&resized, quality)?;
}
if encoded.len() > COVER_OUTPUT_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。",
})),
);
}
Ok(OptimizedCoverUpload {
mime_type: "image/webp".to_string(),
extension: "webp".to_string(),
bytes: encoded,
})
}
fn normalize_cover_crop_rect(
source_width: u32,
source_height: u32,
crop_rect: &CustomWorldCoverCropRect,
) -> Result<NormalizedCropRect, AppError> {
let left = crop_rect
.x
.floor()
.clamp(0.0, source_width.saturating_sub(1) as f64) as u32;
let top = crop_rect
.y
.floor()
.clamp(0.0, source_height.saturating_sub(1) as f64) as u32;
let mut width = crop_rect.width.floor().clamp(1.0, source_width as f64) as u32;
let mut height = crop_rect.height.floor().clamp(1.0, source_height as f64) as u32;
width = width.min(source_width.saturating_sub(left));
height = height.min(source_height.saturating_sub(top));
if width == 0 || height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面裁剪区域不能为空。",
})),
);
}
let ratio = width as f64 / height as f64;
if !(COVER_MIN_RATIO..=COVER_MAX_RATIO).contains(&ratio) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面裁剪区域必须保持 16:9。",
})),
);
}
Ok(NormalizedCropRect {
left,
top,
width,
height,
})
}
fn encode_dynamic_image_to_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>, AppError> {
let prepared = if image.color().has_alpha() {
DynamicImage::ImageRgba8(image.to_rgba8())
} else {
DynamicImage::ImageRgb8(image.to_rgb8())
};
let encoder = WebpEncoder::from_image(&prepared).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-ai",
"message": format!("构造 WebP 编码器失败:{error}"),
}))
})?;
Ok(encoder.encode(quality).to_vec())
}
fn collect_cover_reference_image_sources(
profile: &CoverProfileInput,
requested_role_ids: &[String],
explicit_reference_image_src: &str,
) -> Vec<String> {
let selected_roles = resolve_selected_roles(profile, requested_role_ids);
let mut sources = Vec::new();
push_cover_reference_source(&mut sources, explicit_reference_image_src);
if let Some(opening_act) = resolve_opening_act(profile) {
push_cover_reference_source(
&mut sources,
trim_to_option(opening_act.background_image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
for role in selected_roles {
push_cover_reference_source(
&mut sources,
trim_to_option(role.image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
if let Some(camp) = profile.camp.as_ref() {
push_cover_reference_source(
&mut sources,
trim_to_option(camp.image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
if let Some(landmark) = profile.landmarks.first() {
push_cover_reference_source(
&mut sources,
trim_to_option(landmark.image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
sources.truncate(6);
sources
}
fn push_cover_reference_source(target: &mut Vec<String>, source: &str) {
let Some(normalized) = trim_to_option(Some(source)) else {
return;
};
if !(normalized.starts_with('/') || normalized.starts_with("data:")) {
return;
}
// 参考图源需要保留原始 Data URL / generated 路径,不能做截断,否则会破坏下游解码。
if target.contains(&normalized) {
return;
}
target.push(normalized);
}
fn resolve_selected_roles<'a>(
profile: &'a CoverProfileInput,
requested_role_ids: &[String],
) -> Vec<&'a CoverRoleInput> {
let mut selected = Vec::new();
let mut seen = Vec::new();
for role_id in requested_role_ids {
let Some(role_id) = trim_to_option(Some(role_id.as_str())) else {
continue;
};
if seen.contains(&role_id) {
continue;
}
if let Some(role) = profile
.playable_npcs
.iter()
.find(|role| trim_to_option(role.id.as_deref()).as_deref() == Some(role_id.as_str()))
{
selected.push(role);
seen.push(role_id);
}
if selected.len() >= 3 {
break;
}
}
if !selected.is_empty() {
return selected;
}
profile.playable_npcs.iter().take(3).collect()
}
fn resolve_opening_act(profile: &CoverProfileInput) -> Option<&CoverActInput> {
profile.scene_chapter_blueprints.first()?.acts.first()
}
fn build_cover_prompt_context(
profile: &CoverProfileInput,
requested_role_ids: &[String],
) -> CoverPromptContext {
let opening_act = resolve_opening_act(profile);
let selected_roles = resolve_selected_roles(profile, requested_role_ids);
let role_summary = selected_roles
.iter()
.map(|role| {
[
clamp_cover_text(
trim_to_option(role.name.as_deref())
.unwrap_or_default()
.as_str(),
18,
),
clamp_cover_text(
trim_to_option(role.title.as_deref())
.or_else(|| trim_to_option(role.role.as_deref()))
.unwrap_or_default()
.as_str(),
24,
),
clamp_cover_text(
trim_to_option(role.description.as_deref())
.unwrap_or_default()
.as_str(),
72,
),
]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" / ")
})
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("");
let story_role_summary = profile
.story_npcs
.iter()
.take(4)
.map(|role| {
[
clamp_cover_text(
trim_to_option(role.name.as_deref())
.unwrap_or_default()
.as_str(),
18,
),
clamp_cover_text(
trim_to_option(role.title.as_deref())
.or_else(|| trim_to_option(role.role.as_deref()))
.unwrap_or_default()
.as_str(),
24,
),
]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" / ")
})
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("");
let landmark_summary = profile
.landmarks
.iter()
.take(3)
.map(|landmark| {
[
clamp_cover_text(
trim_to_option(landmark.name.as_deref())
.unwrap_or_default()
.as_str(),
18,
),
clamp_cover_text(
trim_to_option(landmark.description.as_deref())
.unwrap_or_default()
.as_str(),
72,
),
]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" / ")
})
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("");
CoverPromptContext {
opening_act_title: clamp_cover_text(
trim_to_option(opening_act.and_then(|act| act.title.as_deref()))
.unwrap_or_default()
.as_str(),
24,
),
opening_act_summary: clamp_cover_text(
trim_to_option(opening_act.and_then(|act| act.summary.as_deref()))
.unwrap_or_default()
.as_str(),
96,
),
role_summary,
story_role_summary,
landmark_summary,
}
}
fn build_custom_world_cover_image_prompt(
profile: &CoverProfileInput,
requested_role_ids: &[String],
user_prompt: &str,
has_reference_image: bool,
) -> String {
let opening_scene = profile
.camp
.as_ref()
.map(|camp| {
(
trim_to_option(camp.name.as_deref()).unwrap_or_default(),
trim_to_option(camp.description.as_deref()).unwrap_or_default(),
)
})
.or_else(|| {
profile.landmarks.first().map(|landmark| {
(
trim_to_option(landmark.name.as_deref()).unwrap_or_default(),
trim_to_option(landmark.description.as_deref()).unwrap_or_default(),
)
})
});
let prompt_context = build_cover_prompt_context(profile, requested_role_ids);
vec![
"为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。".to_string(),
"画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。"
.to_string(),
"构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。".to_string(),
"不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。".to_string(),
if has_reference_image {
"已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。".to_string()
} else {
String::new()
},
conditional_prompt_line(
"作品名",
clamp_cover_text(
trim_to_option(profile.name.as_deref())
.unwrap_or_default()
.as_str(),
48,
)
.as_str(),
),
conditional_prompt_line(
"副标题",
clamp_cover_text(
trim_to_option(profile.subtitle.as_deref())
.unwrap_or_default()
.as_str(),
48,
)
.as_str(),
),
conditional_prompt_line(
"玩家设定",
clamp_cover_text(
trim_to_option(profile.setting_text.as_deref())
.unwrap_or_default()
.as_str(),
96,
)
.as_str(),
),
conditional_prompt_line(
"世界概述",
clamp_cover_text(
trim_to_option(profile.summary.as_deref())
.unwrap_or_default()
.as_str(),
96,
)
.as_str(),
),
conditional_prompt_line(
"整体基调",
clamp_cover_text(
trim_to_option(profile.tone.as_deref())
.unwrap_or_default()
.as_str(),
72,
)
.as_str(),
),
conditional_prompt_line(
"主线目标",
clamp_cover_text(
trim_to_option(profile.player_goal.as_deref())
.unwrap_or_default()
.as_str(),
72,
)
.as_str(),
),
conditional_prompt_line("开局第一幕标题", prompt_context.opening_act_title.as_str()),
conditional_prompt_line(
"开局第一幕摘要",
prompt_context.opening_act_summary.as_str(),
),
opening_scene
.as_ref()
.map(|(name, _)| conditional_prompt_line("开局场景", name.as_str()))
.unwrap_or_default(),
opening_scene
.as_ref()
.map(|(_, description)| conditional_prompt_line("场景描述", description.as_str()))
.unwrap_or_default(),
conditional_prompt_line("关键场景素材", prompt_context.landmark_summary.as_str()),
conditional_prompt_line("需要出现的角色主形象", prompt_context.role_summary.as_str()),
conditional_prompt_line(
"可辅助参考的场景角色",
prompt_context.story_role_summary.as_str(),
),
conditional_prompt_line("额外要求", user_prompt),
"整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。".to_string(),
]
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn clamp_cover_text(value: &str, max_length: usize) -> String {
clamp_text(value, max_length, false)
}
fn clamp_text(value: &str, max_length: usize, append_ellipsis: bool) -> String {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized = normalized.trim().to_string();
if normalized.is_empty() {
return String::new();
}
if normalized.chars().count() <= max_length {
return normalized;
}
let kept = normalized
.chars()
.take(if append_ellipsis {
max_length.saturating_sub(1)
} else {
max_length
})
.collect::<String>()
.trim()
.to_string();
if append_ellipsis {
format!("{kept}")
} else {
kept
}
}
fn parse_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
if let Some(message) = parsed
.pointer("/error/message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(code) = parsed
.pointer("/error/code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
if let Some(code) = parsed
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
raw_text.trim().to_string()
}
fn map_dashscope_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": message,
}))
}
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_api_error_message(raw_text, fallback_message),
}))
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
if let Some(text) = nested_value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
continue;
}
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "image", &mut urls);
collect_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 normalize_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 mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn clamp_scene_image_text(value: &str, max_length: usize) -> String {
clamp_text(value, max_length, true)
}
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
if value.is_empty() {
String::new()
} else {
format!("{prefix}{value}")
}
}
fn describe_danger_level(danger_level: &str) -> String {
match danger_level.trim().to_ascii_lowercase().as_str() {
"low" | "" => "气氛相对平静,但暗藏细节张力".to_string(),
"medium" | "" => "带有明确的探索压力与潜在威胁".to_string(),
"high" | "" => "危险感强烈,空间中有明显压迫感".to_string(),
"extreme" | "极高" => "极端危险,环境本身就像会吞没闯入者".to_string(),
_ if !danger_level.trim().is_empty() => format!("危险氛围:{}", danger_level.trim()),
_ => "危险气质保持克制但不可忽视".to_string(),
}
}
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
'A'..='Z' => character.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>();
let normalized = collapse_dashes(&normalized);
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
fn collapse_dashes(value: &str) -> String {
value
.chars()
.fold(
(String::new(), false),
|(mut output, last_is_dash), character| {
let is_dash = character == '-';
if is_dash && last_is_dash {
return (output, true);
}
output.push(character);
(output, is_dash)
},
)
.0
.trim_matches('-')
.to_string()
}
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let prefix = "data:";
let separator = ";base64,";
let body = value.strip_prefix(prefix)?;
let (mime_type, data) = body.split_once(separator)?;
if !mime_type.starts_with("image/") {
return None;
}
let bytes = decode_base64(data)?;
Some(ParsedImageDataUrl {
mime_type: mime_type.to_string(),
bytes,
})
}
fn decode_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)
}
fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn trim_to_option(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn current_utc_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}
struct ParsedJsonPayload {
payload: Value,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use axum::response::Response;
use platform_auth::{AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus};
use serde_json::Value;
use time::OffsetDateTime;
fn build_authenticated(state: &AppState) -> AuthenticatedAccessToken {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_custom_world_ai".to_string(),
session_id: "sess_custom_world_ai".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("测试旅人".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
AuthenticatedAccessToken::new(claims)
}
fn build_request_context(operation: &str) -> RequestContext {
RequestContext::new(
"req-custom-world-ai-test".to_string(),
operation.to_string(),
std::time::Duration::ZERO,
true,
)
}
async fn read_error_response(response: Response) -> Value {
use http_body_util::BodyExt as _;
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
serde_json::from_slice(&body).expect("body should be valid json")
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/scene-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_scene_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("世界".to_string()),
landmark_id: Some("landmark_001".to_string()),
landmark_name: Some("遗迹".to_string()),
prompt: Some("测试场景".to_string()),
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: None,
profile: None,
landmark: None,
})),
)
.await
.expect_err("missing dashscope should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("dashscope".to_string())
);
}
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverImageRequest {
profile: CoverProfileInput {
id: Some("profile_001".to_string()),
name: Some("测试世界".to_string()),
..CoverProfileInput::default()
},
user_prompt: Some("测试封面".to_string()),
reference_image_src: None,
character_role_ids: Vec::new(),
size: Some("1600*900".to_string()),
})),
)
.await
.expect_err("missing dashscope should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("dashscope".to_string())
);
}
#[tokio::test]
async fn cover_upload_rejects_invalid_data_url_before_touching_oss() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/custom-world/cover-upload");
let authenticated = build_authenticated(&state);
let response = upload_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverUploadRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("测试世界".to_string()),
image_data_url: "not-a-data-url".to_string(),
crop_rect: CustomWorldCoverCropRect {
x: 0.0,
y: 0.0,
width: 160.0,
height: 90.0,
},
})),
)
.await
.expect_err("invalid data url should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("BAD_REQUEST".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("custom-world-ai".to_string())
);
}
#[test]
fn parse_image_data_url_accepts_image_payload() {
let parsed =
parse_image_data_url("data:image/png;base64,aGVsbG8=").expect("data url should parse");
assert_eq!(parsed.mime_type, "image/png");
assert_eq!(parsed.bytes, b"hello".to_vec());
}
#[test]
fn push_cover_reference_source_keeps_full_data_url() {
let mut sources = Vec::new();
let source = format!("data:image/png;base64,{}", "a".repeat(1024));
push_cover_reference_source(&mut sources, source.as_str());
assert_eq!(sources, vec![source]);
}
#[test]
fn normalize_cover_crop_rect_rejects_non_sixteen_nine_ratio() {
let error = normalize_cover_crop_rect(
1920,
1080,
&CustomWorldCoverCropRect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
},
)
.expect_err("invalid ratio should fail");
assert_eq!(error.code(), "BAD_REQUEST");
}
#[test]
fn optimize_uploaded_cover_image_rejects_oversized_source_before_decoding() {
let error = optimize_uploaded_cover_image(
&ParsedImageDataUrl {
mime_type: "image/png".to_string(),
bytes: vec![0; COVER_UPLOAD_MAX_BYTES + 1],
},
&CustomWorldCoverCropRect {
x: 0.0,
y: 0.0,
width: 160.0,
height: 90.0,
},
)
.expect_err("oversized upload should fail");
assert_eq!(error.code(), "BAD_REQUEST");
}
}