feat: checkpoint m5 and bootstrap m6 asset flow

This commit is contained in:
2026-04-22 14:46:43 +08:00
parent 0773a0d0ca
commit 91fb8edee7
22 changed files with 5096 additions and 445 deletions

View File

@@ -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('>', "&gt;")
}
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());
}
}