feat: complete M6 asset OSS migration

This commit is contained in:
2026-04-22 16:35:41 +08:00
parent d5627c536d
commit 4c8ba535e4
18 changed files with 4911 additions and 40 deletions

View File

@@ -0,0 +1,938 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use module_ai::{
AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind,
AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id,
};
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_json::{Value, json};
use shared_contracts::assets::{
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
CharacterVisualGenerateRequest, CharacterVisualGenerateResponse, CharacterVisualPublishRequest,
CharacterVisualPublishResponse,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual";
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
pub async fn generate_character_visual(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CharacterVisualGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
character_visual_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-visual",
"message": error.body_text(),
})),
)
})?;
// 旧资产工坊接口没有显式 Bearer 头Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
let owner_user_id = "asset-tool".to_string();
let task_id = generate_ai_task_id(current_utc_micros());
let prompt = build_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
let candidate_count = payload.candidate_count.clamp(1, 4);
let created = create_visual_task(
&state,
&task_id,
&owner_user_id,
&character_id,
&model,
&prompt,
)
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
state
.ai_task_service()
.start_task(task_id.as_str(), current_utc_micros())
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.start_stage(
task_id.as_str(),
AiTaskStageKind::PreparePrompt,
current_utc_micros(),
)
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::PreparePrompt,
text_output: Some(prompt.clone()),
structured_payload_json: Some(
json!({
"characterId": character_id,
"sourceMode": payload.source_mode,
"size": size,
"referenceImageCount": payload.reference_image_data_urls.len(),
})
.to_string(),
),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await;
state
.ai_task_service()
.start_stage(
task_id.as_str(),
AiTaskStageKind::RequestModel,
current_utc_micros(),
)
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
text_output: Some(visual_seed.clone()),
structured_payload_json: None,
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
let drafts = persist_visual_drafts(
&state,
&owner_user_id,
&character_id,
&task_id,
&visual_seed,
&size,
candidate_count,
)
.await?;
let result_payload = json!({
"drafts": drafts,
"draftRelativeDir": format!(
"generated-character-drafts/{}/visual/{}",
sanitize_storage_segment(character_id.as_str(), "character"),
task_id
),
});
state
.ai_task_service()
.start_stage(
task_id.as_str(),
AiTaskStageKind::NormalizeResult,
current_utc_micros(),
)
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: None,
structured_payload_json: Some(result_payload.to_string()),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::PersistResult,
text_output: Some("角色主形象候选草稿已写入 OSS。".to_string()),
structured_payload_json: Some(result_payload.to_string()),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_task(task_id.as_str(), current_utc_micros())
.map_err(map_ai_task_error)?;
Ok::<_, AppError>(drafts)
}
.await;
let drafts = match result {
Ok(drafts) => drafts,
Err(error) => {
let _ = state.ai_task_service().fail_task(
created.task_id.as_str(),
error.message().to_string(),
current_utc_micros(),
);
return Err(character_visual_error_response(&request_context, error));
}
};
Ok(json_success_body(
Some(&request_context),
CharacterVisualGenerateResponse {
ok: true,
task_id,
model,
prompt,
drafts,
},
))
}
pub async fn get_character_visual_job(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(task_id): Path<String>,
) -> Result<Json<Value>, Response> {
let task = state
.ai_task_service()
.get_task(task_id.as_str())
.map_err(map_ai_task_error)
.map_err(|error| character_visual_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
build_character_visual_job_payload(task),
))
}
pub async fn publish_character_visual(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CharacterVisualPublishRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
character_visual_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-visual",
"message": error.body_text(),
})),
)
})?;
// 旧资产工坊接口没有显式 Bearer 头Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
let owner_user_id = "asset-tool".to_string();
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
if payload.selected_preview_source.trim().is_empty() {
return Err(character_visual_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-visual",
"message": "selectedPreviewSource is required.",
})),
));
}
let asset_id = format!("visual-{}", current_utc_millis());
let published = persist_published_visual(
&state,
&owner_user_id,
&character_id,
asset_id.as_str(),
payload.selected_preview_source.as_str(),
payload.prompt_text.as_deref(),
)
.await
.map_err(|error| character_visual_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterVisualPublishResponse {
ok: true,
asset_id,
portrait_path: published,
override_map: json!({}),
save_message: if payload.update_character_override == Some(false) {
"主形象已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string()
} else {
"主形象已写入 OSS 并绑定当前角色Rust 后端不再写本地角色覆盖文件。".to_string()
},
},
))
}
fn create_visual_task(
state: &AppState,
task_id: &str,
owner_user_id: &str,
character_id: &str,
model: &str,
prompt: &str,
) -> Result<AiTaskSnapshot, AppError> {
state
.ai_task_service()
.create_task(AiTaskCreateInput {
task_id: task_id.to_string(),
task_kind: AiTaskKind::CustomWorldGeneration,
owner_user_id: owner_user_id.to_string(),
request_label: "生成角色主形象".to_string(),
source_module: "assets.character_visual".to_string(),
source_entity_id: Some(character_id.to_string()),
request_payload_json: Some(
json!({
"characterId": character_id,
"model": model,
"prompt": prompt,
})
.to_string(),
),
stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(),
created_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)
}
async fn generate_visual_seed_with_llm(
state: &AppState,
prompt: &str,
character_id: &str,
) -> String {
let fallback = format!("{character_id}{prompt}");
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。",
),
LlmMessage::user(
json!({
"task": "summarize_character_visual_seed",
"characterId": character_id,
"prompt": prompt,
})
.to_string(),
),
])
.with_max_tokens(96);
llm_client
.request_text(request)
.await
.ok()
.map(|response| response.content.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or(fallback)
}
async fn persist_visual_drafts(
state: &AppState,
owner_user_id: &str,
character_id: &str,
task_id: &str,
visual_seed: &str,
size: &str,
candidate_count: u32,
) -> Result<Vec<CharacterVisualDraftPayload>, AppError> {
let mut drafts = Vec::with_capacity(candidate_count as usize);
for index in 0..candidate_count {
let file_name = format!("candidate-{:02}.svg", index + 1);
let body =
build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str())
.into_bytes();
let put_result = put_character_visual_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
sanitize_storage_segment(character_id, "character"),
"visual".to_string(),
task_id.to_string(),
],
file_name,
"image/svg+xml".to_string(),
body,
build_asset_metadata(
CHARACTER_VISUAL_ASSET_KIND,
owner_user_id,
CHARACTER_VISUAL_ENTITY_KIND,
character_id,
"draft",
),
)
.await?;
drafts.push(CharacterVisualDraftPayload {
id: format!("candidate-{}", index + 1),
label: format!("候选 {}", index + 1),
image_src: put_result.legacy_public_path,
width: parse_size(size).0,
height: parse_size(size).1,
});
}
Ok(drafts)
}
async fn persist_published_visual(
state: &AppState,
owner_user_id: &str,
character_id: &str,
asset_id: &str,
selected_preview_source: &str,
prompt_text: Option<&str>,
) -> Result<String, AppError> {
let oss_client = require_oss_client(state)?;
let http_client = reqwest::Client::new();
let source_object_key = resolve_object_key_from_legacy_path(selected_preview_source)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: source_object_key.clone(),
},
)
.await
.map_err(map_character_visual_oss_error)?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: source_object_key,
expire_seconds: Some(60),
})
.map_err(map_character_visual_oss_error)?;
let source_body = http_client
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取候选主形象失败:{error}"),
}))
})?
.error_for_status()
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取候选主形象失败:{error}"),
}))
})?
.bytes()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取候选主形象内容失败:{error}"),
}))
})?
.to_vec();
let content_type = head
.content_type
.clone()
.unwrap_or_else(|| "image/svg+xml".to_string());
let file_name = match content_type.as_str() {
"image/png" => "master.png",
"image/jpeg" => "master.jpg",
"image/webp" => "master.webp",
_ => "master.svg",
}
.to_string();
let put_result = put_character_visual_object(
state,
LegacyAssetPrefix::Characters,
vec![
sanitize_storage_segment(character_id, "character"),
"visual".to_string(),
asset_id.to_string(),
],
file_name,
content_type.clone(),
source_body,
build_asset_metadata(
CHARACTER_VISUAL_ASSET_KIND,
owner_user_id,
CHARACTER_VISUAL_ENTITY_KIND,
character_id,
CHARACTER_VISUAL_SLOT,
),
)
.await?;
let confirmed = confirm_character_visual_asset_object(
state,
owner_user_id,
character_id,
asset_id,
put_result.object_key.clone(),
content_type,
prompt_text.map(str::to_string),
)
.await?;
bind_character_visual_asset(
state,
owner_user_id,
character_id,
confirmed.record.asset_object_id,
)
.await?;
Ok(put_result.legacy_public_path)
}
async fn put_character_visual_object(
state: &AppState,
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
metadata: BTreeMap<String, String>,
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
let oss_client = require_oss_client(state)?;
oss_client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix,
path_segments,
file_name,
content_type: Some(content_type),
access: OssObjectAccess::Private,
metadata,
body,
},
)
.await
.map_err(map_character_visual_oss_error)
}
async fn confirm_character_visual_asset_object(
state: &AppState,
owner_user_id: &str,
character_id: &str,
source_job_id: &str,
object_key: String,
content_type: String,
prompt_text: Option<String>,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
.await
.map_err(map_character_visual_oss_error)?;
let now_micros = current_utc_micros();
let record = 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(content_type)),
head.content_length,
prompt_text.or(head.etag),
CHARACTER_VISUAL_ASSET_KIND.to_string(),
Some(source_job_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(character_id.to_string()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_character_visual_spacetime_error)?;
let _ = state.ai_task_service().attach_result_reference(
source_job_id,
AiResultReferenceKind::AssetObject,
record.asset_object_id.clone(),
Some("角色主形象正式对象".to_string()),
now_micros,
);
Ok(module_assets::ConfirmAssetObjectResult { record })
}
async fn bind_character_visual_asset(
state: &AppState,
owner_user_id: &str,
character_id: &str,
asset_object_id: String,
) -> Result<(), AppError> {
let now_micros = current_utc_micros();
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object_id,
CHARACTER_VISUAL_ENTITY_KIND.to_string(),
character_id.to_string(),
CHARACTER_VISUAL_SLOT.to_string(),
CHARACTER_VISUAL_ASSET_KIND.to_string(),
Some(owner_user_id.to_string()),
None,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_character_visual_spacetime_error)?;
Ok(())
}
fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
let request_payload = task
.request_payload_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or_else(|| json!({}));
let result = task
.latest_structured_payload_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok());
CharacterAssetJobStatusPayload {
task_id: task.task_id,
kind: "visual".to_string(),
status: match task.status {
AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued,
AiTaskStatus::Running => CharacterAssetJobStatusText::Running,
AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed,
AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed,
},
character_id: request_payload
.get("characterId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
animation: None,
strategy: None,
model: request_payload
.get("model")
.and_then(Value::as_str)
.unwrap_or(CHARACTER_VISUAL_MODEL)
.to_string(),
prompt: request_payload
.get("prompt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
created_at: format_utc_micros(task.created_at_micros),
updated_at: format_utc_micros(task.updated_at_micros),
result,
error_message: task.failure_message,
}
}
fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String {
let merged = [character_brief_text.unwrap_or_default(), prompt_text]
.into_iter()
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n");
format!(
"{}\n单人全身右向斜侧身3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。",
if merged.is_empty() {
"自定义世界角色,服装完整,姿态自然。"
} else {
merged.as_str()
}
)
}
fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String {
let (width, height) = parse_size(size);
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect width="100%" height="100%" fill="#00ff00"/>
<ellipse cx="{shadow_x}" cy="{shadow_y}" rx="{shadow_rx}" ry="{shadow_ry}" fill="rgba(0,0,0,0.18)"/>
<path d="M {body_x} {body_y} C {body_c1x} {body_c1y}, {body_c2x} {body_c2y}, {body_x2} {body_y2} L {leg_x} {leg_y} L {leg2_x} {leg_y} Z" fill="#1f2937"/>
<circle cx="{head_x}" cy="{head_y}" r="{head_r}" fill="#f8d7b0"/>
<path d="M {weapon_x} {weapon_y} L {weapon_x2} {weapon_y2}" stroke="#e5e7eb" stroke-width="{weapon_w}" stroke-linecap="round"/>
<text x="50%" y="{text_y}" text-anchor="middle" fill="#0f172a" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
<text x="50%" y="{sub_y}" text-anchor="middle" fill="#0f172a" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{candidate}</text>
</svg>"##,
width = width,
height = height,
shadow_x = width / 2,
shadow_y = height * 5 / 6,
shadow_rx = width / 5,
shadow_ry = height / 28,
body_x = width * 45 / 100,
body_y = height * 34 / 100,
body_c1x = width * 34 / 100,
body_c1y = height * 50 / 100,
body_c2x = width * 43 / 100,
body_c2y = height * 72 / 100,
body_x2 = width * 56 / 100,
body_y2 = height * 72 / 100,
leg_x = width * 48 / 100,
leg_y = height * 84 / 100,
leg2_x = width * 62 / 100,
head_x = width * 53 / 100,
head_y = height * 25 / 100,
head_r = (width.min(height) / 12).max(18),
weapon_x = width * 57 / 100,
weapon_y = height * 42 / 100,
weapon_x2 = width * 76 / 100,
weapon_y2 = height * 34 / 100,
weapon_w = (width.min(height) / 90).max(4),
text_y = height * 91 / 100,
sub_y = height * 96 / 100,
font_main = (width.min(height) / 28).max(14),
font_sub = (width.min(height) / 36).max(11),
title = escape_svg_text(label),
candidate = escape_svg_text(candidate_label),
)
}
fn resolve_object_key_from_legacy_path(value: &str) -> Result<String, AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-visual",
"message": "selectedPreviewSource is required.",
})),
);
}
if trimmed.starts_with("data:") {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-visual",
"message": "Rust 版 publish 当前要求 selectedPreviewSource 为已写入 OSS 的 /generated-* 路径。",
})));
}
Ok(trimmed.trim_start_matches('/').to_string())
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
entity_kind: &str,
entity_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), entity_kind.to_string()),
("entity_id".to_string(), entity_id.to_string()),
("slot".to_string(), slot.to_string()),
])
}
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})
}
fn normalize_required_text(value: &str, fallback: &str) -> String {
value
.trim()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(180)
.collect::<String>()
.trim()
.to_string()
.if_empty_then(fallback)
}
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_size(size: &str) -> (u32, u32) {
let mut parts = size.split('*');
let width = parts
.next()
.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(1024);
let height = parts
.next()
.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(1024);
(width, height)
}
fn escape_svg_text(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn format_utc_micros(micros: i64) -> String {
module_runtime::format_utc_micros(micros)
}
fn current_utc_millis() -> i64 {
current_utc_micros() / 1_000
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn map_ai_task_error(error: AiTaskServiceError) -> AppError {
let status = match error {
AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND,
AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT,
AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST,
AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_details(json!({
"provider": "ai-task",
"message": error.to_string(),
}))
}
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_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_character_visual_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(),
}))
}
fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
trait EmptyFallback {
fn if_empty_then(self, fallback: &str) -> String;
}
impl EmptyFallback for String {
fn if_empty_then(self, fallback: &str) -> String {
if self.is_empty() {
fallback.to_string()
} else {
self
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_character_visual_prompt_keeps_generation_constraints() {
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
assert!(prompt.contains("潮雾港向导"));
assert!(prompt.contains("右向斜侧身"));
assert!(prompt.contains("纯绿色背景"));
}
#[test]
fn sanitize_storage_segment_keeps_legacy_safe_shape() {
assert_eq!(
sanitize_storage_segment("Harbor Guide/潮雾", "character"),
"harbor-guide"
);
}
}