feat: checkpoint m5 and bootstrap m6 asset flow
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -9,9 +6,15 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
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};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
@@ -87,6 +90,20 @@ struct GeneratedAssetResponse {
|
||||
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>,
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_entity(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -160,8 +177,9 @@ pub async fn generate_custom_world_scene_npc(
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_scene_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldSceneImageRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
@@ -174,30 +192,76 @@ pub async fn generate_custom_world_scene_image(
|
||||
)
|
||||
})?;
|
||||
|
||||
let asset = save_placeholder_asset(
|
||||
"generated-custom-world-scenes",
|
||||
payload
|
||||
.profile_id
|
||||
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 landmark_id = trim_to_option(payload.landmark_id.as_deref());
|
||||
let landmark_name =
|
||||
trim_to_option(payload.landmark_name.as_deref()).unwrap_or_else(|| "scene".to_string());
|
||||
let entity_id = landmark_id.clone().unwrap_or_else(|| landmark_name.clone());
|
||||
let size = payload
|
||||
.size
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("1280*720")
|
||||
.to_string();
|
||||
let prompt = trim_to_option(payload.prompt.as_deref());
|
||||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||||
let svg = build_placeholder_svg(
|
||||
&size,
|
||||
prompt
|
||||
.as_deref()
|
||||
.or(payload.world_name.as_deref())
|
||||
.unwrap_or("world"),
|
||||
payload
|
||||
.landmark_id
|
||||
.as_deref()
|
||||
.or(payload.landmark_name.as_deref())
|
||||
.or(Some(landmark_name.as_str()))
|
||||
.unwrap_or("scene"),
|
||||
"scene",
|
||||
payload.size.as_deref().unwrap_or("1280*720"),
|
||||
payload.prompt.as_deref(),
|
||||
)
|
||||
.into_bytes();
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
profile_id.as_deref().unwrap_or(world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
sanitize_storage_segment(entity_id.as_str(), "scene"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: "scene.svg".to_string(),
|
||||
content_type: "image/svg+xml".to_string(),
|
||||
body: svg,
|
||||
asset_kind: "scene_image",
|
||||
entity_kind: "custom_world_landmark",
|
||||
entity_id,
|
||||
profile_id,
|
||||
slot: "scene_image",
|
||||
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: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some("rust-oss-placeholder".to_string()),
|
||||
size: Some(size),
|
||||
task_id: Some(asset_id),
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: 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>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldCoverImageRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
@@ -210,24 +274,69 @@ pub async fn generate_custom_world_cover_image(
|
||||
)
|
||||
})?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let profile = payload.profile.as_object().cloned().unwrap_or_default();
|
||||
let profile_id = read_string_field(&profile, "id");
|
||||
let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string());
|
||||
let asset = save_placeholder_asset(
|
||||
"generated-custom-world-covers",
|
||||
&read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()),
|
||||
"cover",
|
||||
"cover",
|
||||
payload.size.as_deref().unwrap_or("1600*900"),
|
||||
payload.user_prompt.as_deref(),
|
||||
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
|
||||
let size = payload
|
||||
.size
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("1600*900")
|
||||
.to_string();
|
||||
let prompt = trim_to_option(payload.user_prompt.as_deref());
|
||||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||||
let svg = build_placeholder_svg(
|
||||
&size,
|
||||
prompt
|
||||
.as_deref()
|
||||
.or(Some(world_name.as_str()))
|
||||
.unwrap_or("cover"),
|
||||
)
|
||||
.into_bytes();
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name: "cover.svg".to_string(),
|
||||
content_type: "image/svg+xml".to_string(),
|
||||
body: svg,
|
||||
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: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some("rust-oss-placeholder".to_string()),
|
||||
size: Some(size),
|
||||
task_id: Some(asset_id),
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: 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>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldCoverUploadRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
@@ -249,41 +358,41 @@ pub async fn upload_custom_world_cover_image(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
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 world_segment = sanitize_path_segment(
|
||||
payload
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.or(payload.world_name.as_deref())
|
||||
.unwrap_or("world"),
|
||||
"world",
|
||||
);
|
||||
let relative_dir = PathBuf::from("generated-custom-world-covers")
|
||||
.join(world_segment)
|
||||
.join(&asset_id);
|
||||
let output_dir = resolve_public_output_dir(&relative_dir)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
fs::create_dir_all(&output_dir)
|
||||
.map_err(io_error)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let file_name = match parsed.mime_type.as_str() {
|
||||
"image/png" => "cover.png",
|
||||
"image/webp" => "cover.webp",
|
||||
"image/svg+xml" => "cover.svg",
|
||||
_ => "cover.jpg",
|
||||
}
|
||||
.to_string();
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldCovers,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(entity_id.as_str(), "world"),
|
||||
asset_id.clone(),
|
||||
],
|
||||
file_name,
|
||||
content_type: parsed.mime_type,
|
||||
body: parsed.bytes,
|
||||
asset_kind: "custom_world_cover",
|
||||
entity_kind: "custom_world_profile",
|
||||
entity_id,
|
||||
profile_id,
|
||||
slot: "cover",
|
||||
source_job_id: Some(asset_id.clone()),
|
||||
};
|
||||
fs::write(output_dir.join(file_name), parsed.bytes)
|
||||
.map_err(io_error)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let image_src = format!(
|
||||
"/{}/{}",
|
||||
relative_dir.to_string_lossy().replace('\\', "/"),
|
||||
file_name
|
||||
);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
let asset = persist_custom_world_asset(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src,
|
||||
image_src: String::new(),
|
||||
asset_id,
|
||||
source_type: "uploaded".to_string(),
|
||||
model: None,
|
||||
@@ -292,14 +401,162 @@ pub async fn upload_custom_world_cover_image(
|
||||
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 generate_entity_with_fallback(
|
||||
async fn persist_custom_world_asset(
|
||||
state: &AppState,
|
||||
profile: &Value,
|
||||
kind: &str,
|
||||
) -> Value {
|
||||
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;
|
||||
@@ -447,37 +704,6 @@ fn build_landmark_fallback(world_name: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn save_placeholder_asset(
|
||||
root_segment: &str,
|
||||
world_segment_seed: &str,
|
||||
leaf_segment_seed: &str,
|
||||
file_stem: &str,
|
||||
size: &str,
|
||||
prompt: Option<&str>,
|
||||
) -> Result<GeneratedAssetResponse, AppError> {
|
||||
let asset_id = format!("{file_stem}-{}", current_utc_millis());
|
||||
let relative_dir = PathBuf::from(root_segment)
|
||||
.join(sanitize_path_segment(world_segment_seed, "world"))
|
||||
.join(sanitize_path_segment(leaf_segment_seed, file_stem))
|
||||
.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_placeholder_svg(size, prompt.unwrap_or(file_stem));
|
||||
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
|
||||
|
||||
Ok(GeneratedAssetResponse {
|
||||
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some("rust-placeholder".to_string()),
|
||||
size: Some(size.to_string()),
|
||||
task_id: Some(asset_id),
|
||||
prompt: prompt.map(ToOwned::to_owned),
|
||||
actual_prompt: prompt.map(ToOwned::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_placeholder_svg(size: &str, label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
format!(
|
||||
@@ -493,7 +719,7 @@ fn build_placeholder_svg(size: &str, label: &str) -> String {
|
||||
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,255,255,0.12)"/>
|
||||
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(125,211,252,0.14)"/>
|
||||
<text x="50%" y="46%" text-anchor="middle" fill="#e2e8f0" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust fallback asset</text>
|
||||
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust OSS placeholder</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
@@ -531,36 +757,41 @@ fn escape_svg_text(value: &str) -> String {
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||||
let sanitized = value
|
||||
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
'A'..='Z' => character.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
if sanitized.is_empty() {
|
||||
.collect::<String>();
|
||||
let normalized = collapse_dashes(&normalized);
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
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 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> {
|
||||
@@ -568,6 +799,9 @@ fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
|
||||
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(),
|
||||
@@ -611,6 +845,13 @@ fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
.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()
|
||||
@@ -619,11 +860,12 @@ fn current_utc_millis() -> i64 {
|
||||
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
|
||||
}
|
||||
|
||||
fn io_error(error: std::io::Error) -> AppError {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": format!("文件写入失败:{error}"),
|
||||
}))
|
||||
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 {
|
||||
@@ -634,3 +876,159 @@ struct ParsedImageDataUrl {
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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_oss_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()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect_err("missing oss 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("aliyun-oss".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cover_image_returns_service_unavailable_when_oss_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: json!({
|
||||
"id": "profile_001",
|
||||
"name": "测试世界"
|
||||
}),
|
||||
user_prompt: Some("测试封面".to_string()),
|
||||
size: Some("1600*900".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect_err("missing oss 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("aliyun-oss".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(),
|
||||
})),
|
||||
)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user