refactor: modularize api server assets and handlers

This commit is contained in:
2026-05-14 22:54:52 +08:00
parent 4ba1ebbbdf
commit 1b54db4f92
47 changed files with 8081 additions and 6142 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,645 @@
use super::*;
struct BigFishDashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct BigFishGeneratedImage {
image_url: String,
task_id: String,
}
struct BigFishDownloadedImage {
mime_type: String,
bytes: Vec<u8>,
}
struct BigFishFormalAssetContext {
entity_id: String,
prompt: String,
negative_prompt: String,
size: String,
asset_object_kind: String,
binding_slot: String,
path_segments: Vec<String>,
apply_transparent_background_post_process: bool,
}
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
pub(super) async fn generate_big_fish_formal_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
asset_kind: &str,
level: Option<u32>,
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<String, AppError> {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
}
fn build_big_fish_formal_asset_context(
session: &BigFishSessionRecord,
draft: &BigFishGameDraftRecord,
asset_kind: &str,
level: Option<u32>,
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<BigFishFormalAssetContext, AppError> {
let asset_id = format!("asset-{generated_at_micros}");
match asset_kind {
"level_main_image" => {
let level = find_big_fish_level_blueprint(draft, level)?;
let level_part = build_big_fish_level_part(Some(level.level));
Ok(BigFishFormalAssetContext {
entity_id: session.session_id.clone(),
prompt: build_big_fish_level_main_image_prompt(draft, level),
negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(),
size: "1024*1024".to_string(),
asset_object_kind: "big_fish_level_main_image".to_string(),
binding_slot: format!("level_main_image:{level_part}"),
path_segments: vec![
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
"level-main-image".to_string(),
level_part,
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"level_motion" => {
let level = find_big_fish_level_blueprint(draft, level)?;
let motion_key = motion_key
.map(str::trim)
.filter(|value| matches!(*value, "idle_float" | "move_swim"))
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "motionKey 必须是 idle_float 或 move_swim。",
}))
})?;
let level_part = build_big_fish_level_part(Some(level.level));
Ok(BigFishFormalAssetContext {
entity_id: session.session_id.clone(),
prompt: build_big_fish_level_motion_prompt(draft, level, motion_key),
negative_prompt: BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.to_string(),
size: "1024*1024".to_string(),
asset_object_kind: "big_fish_level_motion".to_string(),
binding_slot: format!("level_motion:{level_part}:{motion_key}"),
path_segments: vec![
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
"level-motion".to_string(),
level_part,
sanitize_big_fish_path_segment(motion_key, "motion"),
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"stage_background" => Ok(BigFishFormalAssetContext {
entity_id: session.session_id.clone(),
prompt: build_big_fish_stage_background_prompt(draft),
negative_prompt: BIG_FISH_DEFAULT_NEGATIVE_PROMPT.to_string(),
size: "720*1280".to_string(),
asset_object_kind: "big_fish_stage_background".to_string(),
binding_slot: "stage_background".to_string(),
path_segments: vec![
sanitize_big_fish_path_segment(session.session_id.as_str(), "session"),
"stage-background".to_string(),
asset_id,
],
apply_transparent_background_post_process: false,
}),
_ => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
})),
),
}
}
fn find_big_fish_level_blueprint(
draft: &BigFishGameDraftRecord,
level: Option<u32>,
) -> Result<&BigFishLevelBlueprintRecord, AppError> {
let level = level.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "level 是等级资产生成的必填项。",
}))
})?;
draft
.levels
.iter()
.find(|blueprint| blueprint.level == level)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": format!("level `{level}` 不存在于当前 Big Fish 草稿。"),
}))
})
}
fn require_big_fish_dashscope_settings(
state: &AppState,
) -> Result<BigFishDashScopeSettings, AppError> {
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.dashscope_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(BigFishDashScopeSettings {
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_big_fish_dashscope_http_client(
settings: &BigFishDashScopeSettings,
) -> 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 create_big_fish_text_to_image_generation(
http_client: &reqwest::Client,
settings: &BigFishDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
) -> Result<BigFishGeneratedImage, 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 !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/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": BIG_FISH_TEXT_TO_IMAGE_MODEL,
"input": {
"prompt": prompt,
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| {
map_big_fish_dashscope_request_error(format!("创建 Big Fish 图片生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("读取 Big Fish 图片生成响应失败:{error}"))
})?;
if !status.is_success() {
return Err(map_big_fish_dashscope_upstream_error(
response_text.as_str(),
"创建 Big Fish 图片生成任务失败",
));
}
let payload =
parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
let task_id = extract_big_fish_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "Big Fish 图片生成任务未返回 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_big_fish_dashscope_request_error(format!(
"查询 Big Fish 图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!(
"读取 Big Fish 图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_big_fish_dashscope_upstream_error(
poll_text.as_str(),
"查询 Big Fish 图片生成任务失败",
));
}
let poll_payload =
parse_big_fish_json_payload(poll_text.as_str(), "解析 Big Fish 图片生成任务响应失败")?;
let task_status = find_first_big_fish_string_by_key(&poll_payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_url = extract_big_fish_image_urls(&poll_payload)
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "Big Fish 图片生成成功但未返回图片地址",
}))
})?;
return Ok(BigFishGeneratedImage { image_url, task_id });
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_big_fish_dashscope_upstream_error(
poll_text.as_str(),
"Big Fish 图片生成任务失败",
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "Big Fish 图片生成超时或未返回图片地址",
})),
)
}
async fn download_big_fish_remote_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
apply_transparent_background_post_process: bool,
) -> Result<BigFishDownloadedImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_big_fish_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_big_fish_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 mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str());
let mut normalized_bytes = bytes.to_vec();
let mut normalized_mime_type = mime_type;
// 中文注释Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。
// 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。
if apply_transparent_background_post_process
&& normalized_mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice())
{
normalized_bytes = optimized;
normalized_mime_type = "image/png".to_string();
}
Ok(BigFishDownloadedImage {
mime_type: normalized_mime_type,
bytes: normalized_bytes,
})
}
async fn persist_big_fish_formal_asset(
state: &AppState,
owner_user_id: &str,
context: &BigFishFormalAssetContext,
generated: BigFishGeneratedImage,
downloaded: BigFishDownloadedImage,
generated_at_micros: i64,
) -> Result<String, 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 image_format = normalize_generated_image_asset_mime(downloaded.mime_type.as_str());
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::BigFishAssets,
path_segments: context.path_segments.clone(),
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: image_format,
bytes: downloaded.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(context.asset_object_kind.clone()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(BIG_FISH_ENTITY_KIND.to_string()),
entity_id: Some(context.entity_id.clone()),
slot: Some(context.binding_slot.clone()),
provider: Some("dashscope".to_string()),
task_id: Some(generated.task_id.clone()),
},
extra_metadata: BTreeMap::new(),
})
.map_err(map_big_fish_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(map_big_fish_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_big_fish_asset_oss_error)?;
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
context.asset_object_kind.clone(),
Some(generated.task_id),
Some(owner_user_id.to_string()),
None,
Some(context.entity_id.clone()),
generated_at_micros,
)
.map_err(map_big_fish_asset_object_prepare_error)?,
)
.await
.map_err(map_big_fish_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
BIG_FISH_ENTITY_KIND.to_string(),
context.entity_id.clone(),
context.binding_slot.clone(),
context.asset_object_kind.clone(),
Some(owner_user_id.to_string()),
None,
generated_at_micros,
)
.map_err(map_big_fish_asset_binding_prepare_error)?,
)
.await
.map_err(map_big_fish_asset_spacetime_error)?;
Ok(put_result.legacy_public_path)
}
fn map_big_fish_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备 Big Fish 图片资产上传请求失败:{error:?}"),
}))
}
fn parse_big_fish_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}{error}"),
}))
})
}
fn extract_big_fish_task_id(payload: &Value) -> Option<String> {
find_first_big_fish_string_by_key(payload, "task_id")
}
fn extract_big_fish_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_big_fish_strings_by_key(payload, "image", &mut urls);
collect_big_fish_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn find_first_big_fish_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_big_fish_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_big_fish_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_big_fish_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, value) in object {
if key == target_key
&& let Some(text) = value.as_str()
{
results.push(text.to_string());
}
collect_big_fish_strings_by_key(value, target_key, results);
}
}
_ => {}
}
}
fn normalize_big_fish_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 map_big_fish_dashscope_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": message,
}))
}
fn map_big_fish_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_big_fish_api_error_message(raw_text, fallback_message),
}))
}
fn parse_big_fish_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
&& let Some(message) = find_first_big_fish_string_by_key(&payload, "message")
.or_else(|| find_first_big_fish_string_by_key(&payload, "code"))
{
return message;
}
let excerpt = trimmed.chars().take(240).collect::<String>();
format!("{fallback_message}{excerpt}")
}
fn map_big_fish_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_big_fish_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_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_big_fish_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn build_big_fish_level_part(level: Option<u32>) -> String {
level
.map(|value| format!("level-{value}"))
.unwrap_or_else(|| "stage".to_string())
}

View File

@@ -0,0 +1,321 @@
use super::*;
pub(super) fn map_big_fish_session_response(session: BigFishSessionRecord) -> BigFishSessionSnapshotResponse {
BigFishSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_big_fish_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_big_fish_draft_response),
asset_slots: session
.asset_slots
.into_iter()
.map(map_big_fish_asset_slot_response)
.collect(),
asset_coverage: map_big_fish_asset_coverage_response(session.asset_coverage),
messages: session
.messages
.into_iter()
.map(map_big_fish_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
publish_ready: session.publish_ready,
updated_at: session.updated_at,
}
}
pub(super) fn map_big_fish_anchor_pack_response(
anchor_pack: BigFishAnchorPackRecord,
) -> BigFishAnchorPackResponse {
BigFishAnchorPackResponse {
gameplay_promise: map_big_fish_anchor_item_response(anchor_pack.gameplay_promise),
ecology_visual_theme: map_big_fish_anchor_item_response(anchor_pack.ecology_visual_theme),
growth_ladder: map_big_fish_anchor_item_response(anchor_pack.growth_ladder),
risk_tempo: map_big_fish_anchor_item_response(anchor_pack.risk_tempo),
}
}
pub(super) fn map_big_fish_anchor_item_response(anchor: BigFishAnchorItemRecord) -> BigFishAnchorItemResponse {
BigFishAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_big_fish_draft_response(draft: BigFishGameDraftRecord) -> BigFishGameDraftResponse {
BigFishGameDraftResponse {
title: draft.title,
subtitle: draft.subtitle,
core_fun: draft.core_fun,
ecology_theme: draft.ecology_theme,
levels: draft
.levels
.into_iter()
.map(map_big_fish_level_response)
.collect(),
background: map_big_fish_background_response(draft.background),
runtime_params: map_big_fish_runtime_params_response(draft.runtime_params),
}
}
pub(super) fn map_big_fish_level_response(
level: BigFishLevelBlueprintRecord,
) -> BigFishLevelBlueprintResponse {
BigFishLevelBlueprintResponse {
level: level.level,
name: level.name,
one_line_fantasy: level.one_line_fantasy,
text_description: level.text_description,
silhouette_direction: level.silhouette_direction,
size_ratio: level.size_ratio,
visual_description: level.visual_description,
visual_prompt_seed: level.visual_prompt_seed,
idle_motion_description: level.idle_motion_description,
move_motion_description: level.move_motion_description,
motion_prompt_seed: level.motion_prompt_seed,
merge_source_level: level.merge_source_level,
prey_window: level.prey_window,
threat_window: level.threat_window,
is_final_level: level.is_final_level,
}
}
pub(super) fn map_big_fish_background_response(
background: BigFishBackgroundBlueprintRecord,
) -> BigFishBackgroundBlueprintResponse {
BigFishBackgroundBlueprintResponse {
theme: background.theme,
color_mood: background.color_mood,
foreground_hints: background.foreground_hints,
midground_composition: background.midground_composition,
background_depth: background.background_depth,
safe_play_area_hint: background.safe_play_area_hint,
spawn_edge_hint: background.spawn_edge_hint,
background_prompt_seed: background.background_prompt_seed,
}
}
pub(super) fn map_big_fish_runtime_params_response(
params: BigFishRuntimeParamsRecord,
) -> BigFishRuntimeParamsResponse {
BigFishRuntimeParamsResponse {
level_count: params.level_count,
merge_count_per_upgrade: params.merge_count_per_upgrade,
spawn_target_count: params.spawn_target_count,
leader_move_speed: params.leader_move_speed,
follower_catch_up_speed: params.follower_catch_up_speed,
offscreen_cull_seconds: params.offscreen_cull_seconds,
prey_spawn_delta_levels: params.prey_spawn_delta_levels,
threat_spawn_delta_levels: params.threat_spawn_delta_levels,
win_level: params.win_level,
}
}
pub(super) fn map_big_fish_asset_slot_response(slot: BigFishAssetSlotRecord) -> BigFishAssetSlotResponse {
BigFishAssetSlotResponse {
slot_id: slot.slot_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at: slot.updated_at,
}
}
pub(super) fn map_big_fish_asset_coverage_response(
coverage: BigFishAssetCoverageRecord,
) -> BigFishAssetCoverageResponse {
BigFishAssetCoverageResponse {
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
required_level_count: coverage.required_level_count,
publish_ready: coverage.publish_ready,
blockers: coverage.blockers,
}
}
pub(super) fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
BigFishRuntimeSnapshotResponse {
run_id: run.run_id,
session_id: run.session_id,
status: run.status,
tick: run.tick,
player_level: run.player_level,
win_level: run.win_level,
leader_entity_id: run.leader_entity_id,
owned_entities: run
.owned_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
wild_entities: run
.wild_entities
.into_iter()
.map(map_big_fish_runtime_entity_response)
.collect(),
camera_center: map_big_fish_vector2_response(run.camera_center),
last_input: map_big_fish_vector2_response(run.last_input),
event_log: run.event_log,
updated_at: run.updated_at,
}
}
pub(super) fn map_big_fish_runtime_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
BigFishRuntimeEntityResponse {
entity_id: entity.entity_id,
level: entity.level,
position: map_big_fish_vector2_response(entity.position),
radius: entity.radius,
offscreen_seconds: entity.offscreen_seconds,
}
}
pub(super) fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
BigFishVector2Response {
x: vector.x,
y: vector.y,
}
}
pub(super) async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
owner_user_id: String,
now: i64,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
let session =
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
let compiled_draft =
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
let draft_json = serde_json::to_string(&compiled_draft).ok();
state
.spacetime_client()
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
session_id,
owner_user_id,
draft_json,
compiled_at_micros: now,
})
.await
}
pub(super) async fn load_big_fish_session_with_retry(
state: &AppState,
session_id: String,
owner_user_id: String,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let mut last_retryable_error = None;
for attempt in 0..2 {
match state
.spacetime_client()
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => return Ok(session),
Err(error @ SpacetimeClientError::Timeout)
| Err(error @ SpacetimeClientError::ConnectDropped) => {
last_retryable_error = Some(error);
if attempt == 0 {
sleep(Duration::from_millis(250)).await;
continue;
}
}
Err(error) => return Err(error),
}
}
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
}
pub(super) fn map_record_anchor_pack_to_domain(
anchor_pack: &BigFishAnchorPackRecord,
) -> module_big_fish::BigFishAnchorPack {
module_big_fish::BigFishAnchorPack {
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
}
}
pub(super) fn map_record_anchor_item_to_domain(
anchor_item: &BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: anchor_item.key.clone(),
label: anchor_item.label.clone(),
value: anchor_item.value.clone(),
status: match anchor_item.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
pub(super) fn map_big_fish_agent_message_response(
message: BigFishAgentMessageRecord,
) -> BigFishAgentMessageResponse {
BigFishAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_big_fish_work_summary_response(
state: &AppState,
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
let author = resolve_work_author_by_user_id(state, &item.owner_user_id, None, None);
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
owner_user_id: item.owner_user_id,
author_display_name: author.display_name,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
published_at: item
.published_at_micros
.map(current_timestamp_micros_to_string),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
}
}
pub(super) fn build_big_fish_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "我会先帮你确定大鱼吃小鱼的核心锚点。可以从主题生态、成长阶梯或风险节奏开始。"
.to_string();
}
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
}

View File

@@ -2770,383 +2770,9 @@ async fn upsert_custom_world_draft_foundation_progress(
})
}
fn map_custom_world_library_entry_response(
state: &AppState,
entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
entry.author_public_user_code.as_deref(),
);
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.or(entry.author_public_user_code),
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: 0,
}
}
mod mappers;
fn map_custom_world_library_entry_response_from_work_summary(
state: &AppState,
item: CustomWorldWorkSummaryRecord,
owner_user_id: &str,
) -> Option<CustomWorldLibraryEntryResponse> {
let profile_id = item.profile_id.as_ref()?.clone();
let profile = build_custom_world_library_list_profile_payload(&item, &profile_id);
let author = resolve_work_author_by_user_id(state, owner_user_id, None, None);
Some(CustomWorldLibraryEntryResponse {
owner_user_id: owner_user_id.to_string(),
public_work_code: (item.status == "published")
.then(|| build_public_work_code_from_profile_id(&profile_id)),
profile_id,
author_public_user_code: author.public_user_code,
profile,
visibility: item.status,
published_at: item.published_at,
updated_at: item.updated_at,
author_display_name: author.display_name,
world_name: item.title,
subtitle: item.subtitle,
summary_text: item.summary,
cover_image_src: item.cover_image_src,
theme_mode: "mythic".to_string(),
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
})
}
fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
fn build_custom_world_library_list_profile_payload(
item: &CustomWorldWorkSummaryRecord,
profile_id: &str,
) -> Value {
json!({
"id": profile_id,
"name": item.title,
"subtitle": item.subtitle,
"summary": item.summary,
"tone": "",
"playerGoal": "",
"settingText": "",
"themeMode": "mythic",
"templateWorldType": "WUXIA",
"compatibilityTemplateWorldType": Value::Null,
"cover": item.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": [],
"coreConflicts": [],
"playableNpcs": [],
"storyNpcs": [],
"items": [],
"camp": Value::Null,
"landmarks": [],
"ownedSettingLayers": Value::Null,
})
}
fn map_custom_world_gallery_card_response(
state: &AppState,
entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
Some(&entry.author_public_user_code),
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author
.public_user_code
.unwrap_or(entry.author_public_user_code),
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
CustomWorldAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
anchor_content: session.anchor_content,
progress_percent: session.progress_percent,
last_assistant_reply: session.last_assistant_reply,
stage: session.stage,
focus_card_id: session.focus_card_id,
creator_intent: session.creator_intent,
creator_intent_readiness: session.creator_intent_readiness,
anchor_pack: session.anchor_pack,
lock_state: session.lock_state,
draft_profile: session.draft_profile,
messages: session
.messages
.into_iter()
.map(map_custom_world_agent_message_response)
.collect(),
draft_cards: session
.draft_cards
.into_iter()
.map(map_custom_world_draft_card_response)
.collect(),
pending_clarifications: session.pending_clarifications,
suggested_actions: session.suggested_actions,
recommended_replies: session.recommended_replies,
quality_findings: session.quality_findings,
asset_coverage: session.asset_coverage,
checkpoints: session
.checkpoints
.into_iter()
.map(map_custom_world_agent_checkpoint_response)
.collect(),
supported_actions: session
.supported_actions
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
fn build_custom_world_creation_result_view_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldCreationResultViewResponse {
let profile_from_preview = session
.result_preview
.as_ref()
.and_then(|preview| preview.get("preview"))
.and_then(normalize_json_object_value);
let profile_from_draft =
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
normalize_json_object_value(&session.draft_profile)
// 中文注释legacyResultProfile 只在服务端作为历史会话恢复兜底,
// 前端不再直接解释 legacy 字段的真相优先级。
.or_else(|| {
session
.draft_profile
.get("legacyResultProfile")
.and_then(normalize_json_object_value)
})
} else {
None
};
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
(Some(profile), _) => (Some(profile), "result_preview"),
(None, Some(profile)) => (Some(profile), "draft_profile"),
(None, None) => (None, "none"),
};
let publish_ready = session
.publish_gate
.as_ref()
.map(|gate| gate.publish_ready)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("publishReady"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let can_enter_world = session
.publish_gate
.as_ref()
.map(|gate| gate.can_enter_world)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("canEnterWorld"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let blocker_count = session
.publish_gate
.as_ref()
.map(|gate| gate.blocker_count)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("blockers"))
.and_then(Value::as_array)
.map(|items| items.len() as u32)
})
.unwrap_or(0);
let has_profile = profile.is_some();
let generation_failed = session.stage == "error"
|| session
.messages
.iter()
.any(|message| message.kind == "warning" && message.text.contains("失败"));
let result_stage = is_agent_result_stage(session.stage.as_str());
let (
target_stage,
generation_view_source,
result_view_source,
recovery_action,
recovery_reason,
) = if has_profile && result_stage {
(
"custom-world-result",
None,
Some("agent-draft"),
"open_result",
None,
)
} else if generation_failed {
(
"custom-world-generating",
Some("agent-draft-foundation"),
None,
"resume_generation",
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
)
} else {
(
"agent-workspace",
None,
None,
"continue_agent",
Some("当前会话还没有可打开的结果页真相源。"),
)
};
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
CustomWorldCreationResultViewResponse {
session: map_custom_world_agent_session_response(session),
profile,
profile_source: profile_source.to_string(),
target_stage: target_stage.to_string(),
generation_view_source: generation_view_source.map(ToOwned::to_owned),
result_view_source: result_view_source.map(ToOwned::to_owned),
can_autosave_library: has_profile && result_stage,
can_sync_result_profile,
publish_ready,
can_enter_world,
blocker_count,
recovery_action: recovery_action.to_string(),
recovery_reason: recovery_reason.map(ToOwned::to_owned),
}
}
fn is_agent_result_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining"
| "visual_refining"
| "long_tail_review"
| "ready_to_publish"
| "published"
)
}
fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
)
}
fn normalize_json_object_value(value: &Value) -> Option<Value> {
value.as_object().and_then(|object| {
if object.is_empty() {
None
} else {
Some(Value::Object(object.clone()))
}
})
}
use mappers::*;
fn log_custom_world_publish_gate_diagnostics(
source: &str,

View File

@@ -0,0 +1,380 @@
use super::*;
pub(super) fn map_custom_world_library_entry_response(
state: &AppState,
entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
entry.author_public_user_code.as_deref(),
);
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author.public_user_code.or(entry.author_public_user_code),
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: 0,
}
}
pub(super) fn map_custom_world_library_entry_response_from_work_summary(
state: &AppState,
item: CustomWorldWorkSummaryRecord,
owner_user_id: &str,
) -> Option<CustomWorldLibraryEntryResponse> {
let profile_id = item.profile_id.as_ref()?.clone();
let profile = build_custom_world_library_list_profile_payload(&item, &profile_id);
let author = resolve_work_author_by_user_id(state, owner_user_id, None, None);
Some(CustomWorldLibraryEntryResponse {
owner_user_id: owner_user_id.to_string(),
public_work_code: (item.status == "published")
.then(|| build_public_work_code_from_profile_id(&profile_id)),
profile_id,
author_public_user_code: author.public_user_code,
profile,
visibility: item.status,
published_at: item.published_at,
updated_at: item.updated_at,
author_display_name: author.display_name,
world_name: item.title,
subtitle: item.subtitle,
summary_text: item.summary,
cover_image_src: item.cover_image_src,
theme_mode: "mythic".to_string(),
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
})
}
pub(super) fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
pub(super) fn build_custom_world_library_list_profile_payload(
item: &CustomWorldWorkSummaryRecord,
profile_id: &str,
) -> Value {
json!({
"id": profile_id,
"name": item.title,
"subtitle": item.subtitle,
"summary": item.summary,
"tone": "",
"playerGoal": "",
"settingText": "",
"themeMode": "mythic",
"templateWorldType": "WUXIA",
"compatibilityTemplateWorldType": Value::Null,
"cover": item.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": [],
"coreConflicts": [],
"playableNpcs": [],
"storyNpcs": [],
"items": [],
"camp": Value::Null,
"landmarks": [],
"ownedSettingLayers": Value::Null,
})
}
pub(super) fn map_custom_world_gallery_card_response(
state: &AppState,
entry: CustomWorldGalleryEntryRecord,
) -> CustomWorldGalleryCardResponse {
let author = resolve_work_author_by_user_id(
state,
&entry.owner_user_id,
Some(&entry.author_display_name),
Some(&entry.author_public_user_code),
);
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: author
.public_user_code
.unwrap_or(entry.author_public_user_code),
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
author_display_name: author.display_name,
world_name: entry.world_name,
subtitle: entry.subtitle,
summary_text: entry.summary_text,
cover_image_src: entry.cover_image_src,
theme_mode: entry.theme_mode,
playable_npc_count: entry.playable_npc_count,
landmark_count: entry.landmark_count,
play_count: entry.play_count,
remix_count: entry.remix_count,
like_count: entry.like_count,
recent_play_count_7d: entry.recent_play_count_7d,
}
}
pub(super) fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
pub(super) fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
CustomWorldAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
anchor_content: session.anchor_content,
progress_percent: session.progress_percent,
last_assistant_reply: session.last_assistant_reply,
stage: session.stage,
focus_card_id: session.focus_card_id,
creator_intent: session.creator_intent,
creator_intent_readiness: session.creator_intent_readiness,
anchor_pack: session.anchor_pack,
lock_state: session.lock_state,
draft_profile: session.draft_profile,
messages: session
.messages
.into_iter()
.map(map_custom_world_agent_message_response)
.collect(),
draft_cards: session
.draft_cards
.into_iter()
.map(map_custom_world_draft_card_response)
.collect(),
pending_clarifications: session.pending_clarifications,
suggested_actions: session.suggested_actions,
recommended_replies: session.recommended_replies,
quality_findings: session.quality_findings,
asset_coverage: session.asset_coverage,
checkpoints: session
.checkpoints
.into_iter()
.map(map_custom_world_agent_checkpoint_response)
.collect(),
supported_actions: session
.supported_actions
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session
.publish_gate
.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
pub(super) fn build_custom_world_creation_result_view_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldCreationResultViewResponse {
let profile_from_preview = session
.result_preview
.as_ref()
.and_then(|preview| preview.get("preview"))
.and_then(normalize_json_object_value);
let profile_from_draft =
if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) {
normalize_json_object_value(&session.draft_profile)
// 中文注释legacyResultProfile 只在服务端作为历史会话恢复兜底,
// 前端不再直接解释 legacy 字段的真相优先级。
.or_else(|| {
session
.draft_profile
.get("legacyResultProfile")
.and_then(normalize_json_object_value)
})
} else {
None
};
let (profile, profile_source) = match (profile_from_preview, profile_from_draft) {
(Some(profile), _) => (Some(profile), "result_preview"),
(None, Some(profile)) => (Some(profile), "draft_profile"),
(None, None) => (None, "none"),
};
let publish_ready = session
.publish_gate
.as_ref()
.map(|gate| gate.publish_ready)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("publishReady"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let can_enter_world = session
.publish_gate
.as_ref()
.map(|gate| gate.can_enter_world)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("canEnterWorld"))
.and_then(Value::as_bool)
})
.unwrap_or(false);
let blocker_count = session
.publish_gate
.as_ref()
.map(|gate| gate.blocker_count)
.or_else(|| {
session
.result_preview
.as_ref()
.and_then(|preview| preview.get("blockers"))
.and_then(Value::as_array)
.map(|items| items.len() as u32)
})
.unwrap_or(0);
let has_profile = profile.is_some();
let generation_failed = session.stage == "error"
|| session
.messages
.iter()
.any(|message| message.kind == "warning" && message.text.contains("失败"));
let result_stage = is_agent_result_stage(session.stage.as_str());
let (
target_stage,
generation_view_source,
result_view_source,
recovery_action,
recovery_reason,
) = if has_profile && result_stage {
(
"custom-world-result",
None,
Some("agent-draft"),
"open_result",
None,
)
} else if generation_failed {
(
"custom-world-generating",
Some("agent-draft-foundation"),
None,
"resume_generation",
Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"),
)
} else {
(
"agent-workspace",
None,
None,
"continue_agent",
Some("当前会话还没有可打开的结果页真相源。"),
)
};
let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str());
CustomWorldCreationResultViewResponse {
session: map_custom_world_agent_session_response(session),
profile,
profile_source: profile_source.to_string(),
target_stage: target_stage.to_string(),
generation_view_source: generation_view_source.map(ToOwned::to_owned),
result_view_source: result_view_source.map(ToOwned::to_owned),
can_autosave_library: has_profile && result_stage,
can_sync_result_profile,
publish_ready,
can_enter_world,
blocker_count,
recovery_action: recovery_action.to_string(),
recovery_reason: recovery_reason.map(ToOwned::to_owned),
}
}
pub(super) fn is_agent_result_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining"
| "visual_refining"
| "long_tail_review"
| "ready_to_publish"
| "published"
)
}
pub(super) fn is_agent_result_profile_sync_stage(stage: &str) -> bool {
matches!(
stage,
"object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish"
)
}
pub(super) fn normalize_json_object_value(value: &Value) -> Option<Value> {
value.as_object().and_then(|object| {
if object.is_empty() {
None
} else {
Some(Value::Object(object.clone()))
}
})
}

View File

@@ -17,8 +17,7 @@ use module_assets::{
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
@@ -26,6 +25,11 @@ use spacetime_client::SpacetimeClientError;
use tokio::time::sleep;
use webp::Encoder as WebpEncoder;
use crate::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
normalize_generated_image_asset_mime,
};
use crate::{
api_response::json_success_body,
asset_billing::{execute_billable_asset_operation, execute_billable_asset_operation_with_cost},
@@ -1084,482 +1088,15 @@ pub async fn generate_custom_world_opening_cg(
))
}
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)
}
mod assets;
async fn generate_opening_cg_storyboard(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
normalized: &NormalizedOpeningCgRequest,
reference_images: &[String],
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
let generated = create_openai_image_generation(
http_client,
settings,
normalized.storyboard_prompt.as_str(),
None,
OPENING_CG_STORYBOARD_IMAGE_SIZE,
1,
reference_images,
"开局 CG 故事板生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "开局 CG 故事板生成成功但未返回图片。",
}))
})?;
let asset_id = format!("opening-cg-storyboard-{}", 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",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("storyboard.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_STORYBOARD_SLOT,
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(GPT_IMAGE_2_MODEL.to_string()),
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
task_id: Some(generated.task_id.clone()),
prompt: Some(normalized.storyboard_prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
use assets::persist_custom_world_asset;
Ok(GeneratedOpeningCgStoryboard {
image_src: asset.image_src,
asset_id,
})
}
mod opening_cg;
async fn generate_opening_cg_video(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
normalized: &NormalizedOpeningCgRequest,
storyboard_reference_data_url: &str,
) -> Result<GeneratedOpeningCgVideo, AppError> {
let upstream_task_id = create_ark_storyboard_to_video_task(
http_client,
settings,
normalized.video_prompt.as_str(),
storyboard_reference_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
.await?;
let downloaded =
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
let video_src = persist_opening_cg_video_asset(
state,
owner_user_id,
normalized,
asset_id.as_str(),
Some(upstream_task_id.clone()),
downloaded,
)
.await?;
Ok(GeneratedOpeningCgVideo {
video_src,
asset_id,
})
}
async fn persist_opening_cg_video_asset(
state: &AppState,
owner_user_id: &str,
normalized: &NormalizedOpeningCgRequest,
asset_id: &str,
source_job_id: Option<String>,
video: DownloadedRemoteVideo,
) -> Result<String, AppError> {
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",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("opening.{}", video.extension),
content_type: video.mime_type,
body: video.bytes,
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_VIDEO_SLOT,
source_job_id,
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.to_string(),
source_type: "generated".to_string(),
model: Some("ark-seedance".to_string()),
size: Some(format!(
"{}:{}:{}s",
OPENING_CG_VIDEO_RESOLUTION,
OPENING_CG_VIDEO_RATIO,
OPENING_CG_VIDEO_DURATION_SECONDS
)),
task_id: None,
prompt: Some(normalized.video_prompt.clone()),
actual_prompt: None,
},
)
.await?;
Ok(asset.image_src)
}
async fn create_ark_storyboard_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
storyboard_reference_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": storyboard_reference_data_url,
},
"role": "reference_image",
}
],
"resolution": OPENING_CG_VIDEO_RESOLUTION,
"ratio": OPENING_CG_VIDEO_RATIO,
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
"watermark": false,
"audio": true,
"generate_audio": true,
"web_search": true,
"enable_web_search": true,
}))
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"创建开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
extract_ark_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务未返回任务 id。",
}))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"查询开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务完成但没有返回 video_url。",
"taskId": task_id,
})),
);
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"开局 CG 视频任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频生成超时,请稍后重试。",
"taskId": task_id,
})),
)
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteVideo, AppError> {
let response = http_client
.get(video_url)
.send()
.await
.map_err(|error| map_ark_video_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("video/mp4")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
Ok(DownloadedRemoteVideo {
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
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 {
map_oss_error(error, "aliyun-oss")
}
use opening_cg::{
generate_opening_cg_storyboard, generate_opening_cg_video, map_custom_world_asset_oss_error,
};
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
let fallback = build_entity_fallback(profile, kind);

View File

@@ -0,0 +1,122 @@
use super::*;
use super::opening_cg::{
map_asset_binding_prepare_error, map_asset_object_prepare_error,
map_custom_world_asset_oss_error, map_custom_world_asset_spacetime_error,
map_custom_world_generated_image_asset_error,
};
pub(super) 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 PreparedAssetUpload {
prefix,
path_segments,
file_name,
content_type,
body,
asset_kind,
entity_kind,
entity_id,
profile_id,
slot,
source_job_id,
} = upload;
let file_stem = file_name
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(file_name.as_str())
.to_string();
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix,
path_segments,
file_stem,
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(content_type.as_str()),
bytes: body,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(entity_kind.to_string()),
entity_id: Some(entity_id.clone()),
slot: Some(slot.to_string()),
provider: None,
task_id: source_job_id.clone(),
},
extra_metadata: profile_id
.as_ref()
.map(|profile_id| BTreeMap::from([("profile_id".to_string(), profile_id.clone())]))
.unwrap_or_default(),
})
.map_err(map_custom_world_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.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(persisted_mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
source_job_id,
Some(owner_user_id.to_string()),
profile_id.clone(),
Some(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,
entity_kind.to_string(),
entity_id.clone(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
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)
}

View File

@@ -0,0 +1,377 @@
use super::*;
pub(super) async fn generate_opening_cg_storyboard(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
normalized: &NormalizedOpeningCgRequest,
reference_images: &[String],
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
let generated = create_openai_image_generation(
http_client,
settings,
normalized.storyboard_prompt.as_str(),
None,
OPENING_CG_STORYBOARD_IMAGE_SIZE,
1,
reference_images,
"开局 CG 故事板生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "开局 CG 故事板生成成功但未返回图片。",
}))
})?;
let asset_id = format!("opening-cg-storyboard-{}", 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",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("storyboard.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_STORYBOARD_SLOT,
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(GPT_IMAGE_2_MODEL.to_string()),
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
task_id: Some(generated.task_id.clone()),
prompt: Some(normalized.storyboard_prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedOpeningCgStoryboard {
image_src: asset.image_src,
asset_id,
})
}
pub(super) async fn generate_opening_cg_video(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
normalized: &NormalizedOpeningCgRequest,
storyboard_reference_data_url: &str,
) -> Result<GeneratedOpeningCgVideo, AppError> {
let upstream_task_id = create_ark_storyboard_to_video_task(
http_client,
settings,
normalized.video_prompt.as_str(),
storyboard_reference_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
.await?;
let downloaded =
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
let video_src = persist_opening_cg_video_asset(
state,
owner_user_id,
normalized,
asset_id.as_str(),
Some(upstream_task_id.clone()),
downloaded,
)
.await?;
Ok(GeneratedOpeningCgVideo {
video_src,
asset_id,
})
}
pub(super) async fn persist_opening_cg_video_asset(
state: &AppState,
owner_user_id: &str,
normalized: &NormalizedOpeningCgRequest,
asset_id: &str,
source_job_id: Option<String>,
video: DownloadedRemoteVideo,
) -> Result<String, AppError> {
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",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("opening.{}", video.extension),
content_type: video.mime_type,
body: video.bytes,
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_VIDEO_SLOT,
source_job_id,
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.to_string(),
source_type: "generated".to_string(),
model: Some("ark-seedance".to_string()),
size: Some(format!(
"{}:{}:{}s",
OPENING_CG_VIDEO_RESOLUTION,
OPENING_CG_VIDEO_RATIO,
OPENING_CG_VIDEO_DURATION_SECONDS
)),
task_id: None,
prompt: Some(normalized.video_prompt.clone()),
actual_prompt: None,
},
)
.await?;
Ok(asset.image_src)
}
async fn create_ark_storyboard_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
storyboard_reference_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": storyboard_reference_data_url,
},
"role": "reference_image",
}
],
"resolution": OPENING_CG_VIDEO_RESOLUTION,
"ratio": OPENING_CG_VIDEO_RATIO,
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
"watermark": false,
"audio": true,
"generate_audio": true,
"web_search": true,
"enable_web_search": true,
}))
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"创建开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
extract_ark_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务未返回任务 id。",
}))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"查询开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务完成但没有返回 video_url。",
"taskId": task_id,
})),
);
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"开局 CG 视频任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频生成超时,请稍后重试。",
"taskId": task_id,
})),
)
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteVideo, AppError> {
let response = http_client
.get(video_url)
.send()
.await
.map_err(|error| map_ark_video_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("video/mp4")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
Ok(DownloadedRemoteVideo {
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
pub(super) fn map_custom_world_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备自定义世界图片资产上传请求失败:{error:?}"),
}))
}
pub(super) 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(),
}))
}
pub(super) 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(),
}))
}
pub(super) 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(),
}))
}
pub(super) fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}

View File

@@ -0,0 +1,163 @@
use std::collections::BTreeMap;
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use super::helpers::{
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
build_generated_image_asset_storage_paths, merge_generated_image_asset_metadata,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetAdapterBoundary;
impl GeneratedImageAssetAdapterBoundary {
pub(crate) const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence.";
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetAdapter;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetPersistInput {
pub(crate) prefix: LegacyAssetPrefix,
pub(crate) path_segments: Vec<String>,
pub(crate) file_stem: String,
pub(crate) image: GeneratedImageAssetDataUrl,
pub(crate) access: OssObjectAccess,
pub(crate) metadata: GeneratedImageAssetAdapterMetadata,
pub(crate) extra_metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetAdapterMetadata {
pub(crate) asset_kind: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) entity_kind: Option<String>,
pub(crate) entity_id: Option<String>,
pub(crate) slot: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetPreparedPut {
pub(crate) request: OssPutObjectRequest,
pub(crate) storage_paths: GeneratedImageAssetStoragePaths,
pub(crate) format: GeneratedImageAssetImageFormat,
}
impl GeneratedImageAssetAdapter {
/// Adapter boundary: this skeleton intentionally does not read, reserve, charge, refund,
/// or otherwise mutate billing state. Real callers must keep billing orchestration outside
/// generated_image_assets when they migrate onto this adapter.
pub(crate) fn prepare_put_object(
input: GeneratedImageAssetPersistInput,
) -> Result<GeneratedImageAssetPreparedPut, GeneratedImageAssetHelperError> {
let file_name = format!(
"{}.{}",
input.file_stem.trim(),
input.image.format.extension
);
let storage_paths = build_generated_image_asset_storage_paths(
input.prefix,
&input.path_segments,
file_name.as_str(),
)?;
let metadata = merge_generated_image_asset_metadata(
build_generated_image_asset_metadata(input.metadata.into()),
input.extra_metadata,
);
let format = input.image.format.clone();
Ok(GeneratedImageAssetPreparedPut {
request: OssPutObjectRequest {
prefix: input.prefix,
path_segments: input.path_segments,
file_name,
content_type: Some(format.mime_type.clone()),
access: input.access,
metadata,
body: input.image.bytes,
},
storage_paths,
format,
})
}
}
impl From<GeneratedImageAssetAdapterMetadata> for super::helpers::GeneratedImageAssetMetadataInput {
fn from(value: GeneratedImageAssetAdapterMetadata) -> Self {
Self {
asset_kind: value.asset_kind,
owner_user_id: value.owner_user_id,
entity_kind: value.entity_kind,
entity_id: value.entity_id,
slot: value.slot,
provider: value.provider,
task_id: value.task_id,
}
}
}
#[cfg(test)]
mod generated_image_assets_adapter_tests {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use super::*;
use crate::generated_image_assets::helpers::decode_generated_image_asset_data_url;
#[test]
fn generated_image_assets_adapter_prepares_put_without_billing_side_effects() {
let image = decode_generated_image_asset_data_url(&format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"png bytes")
))
.expect("image should decode");
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec!["work/1".to_string(), "cover".to_string()],
file_stem: "image".to_string(),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some("square-hole-cover".to_string()),
owner_user_id: Some("user-1".to_string()),
entity_kind: Some("work".to_string()),
entity_id: Some("work-1".to_string()),
slot: Some("cover".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some("task-1".to_string()),
},
extra_metadata: BTreeMap::from([("caller".to_string(), "unit-test".to_string())]),
})
.expect("put object should be prepared");
assert_eq!(
GeneratedImageAssetAdapterBoundary::BILLING_BOUNDARY_COMMENT,
"generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."
);
assert_eq!(prepared.request.prefix, LegacyAssetPrefix::SquareHoleAssets);
assert_eq!(prepared.request.file_name, "image.png");
assert_eq!(prepared.request.content_type, Some("image/png".to_string()));
assert_eq!(prepared.request.body, b"png bytes");
assert_eq!(
prepared.storage_paths.object_key,
"generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.storage_paths.legacy_public_path,
"/generated-square-hole-assets/work-1/cover/image.png"
);
assert_eq!(
prepared.request.metadata.get("asset_kind"),
Some(&"square-hole-cover".to_string())
);
assert_eq!(
prepared.request.metadata.get("caller"),
Some(&"unit-test".to_string())
);
}
}

View File

@@ -0,0 +1,291 @@
use std::collections::BTreeMap;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_oss::LegacyAssetPrefix;
const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetImageFormat {
pub(crate) mime_type: String,
pub(crate) extension: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetDataUrl {
pub(crate) format: GeneratedImageAssetImageFormat,
pub(crate) bytes: Vec<u8>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetMetadataInput {
pub(crate) asset_kind: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) entity_kind: Option<String>,
pub(crate) entity_id: Option<String>,
pub(crate) slot: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedImageAssetStoragePaths {
pub(crate) object_key: String,
pub(crate) legacy_public_path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum GeneratedImageAssetHelperError {
InvalidDataUrl,
UnsupportedEncoding,
DecodeBase64(String),
InvalidFileName,
}
pub(crate) fn normalize_generated_image_asset_mime(
raw_content_type: impl AsRef<str>,
) -> GeneratedImageAssetImageFormat {
let mime_type = raw_content_type
.as_ref()
.split(';')
.next()
.map(str::trim)
.unwrap_or(DEFAULT_IMAGE_MIME)
.to_ascii_lowercase();
match mime_type.as_str() {
"image/png" => image_format("image/png", "png"),
"image/webp" => image_format("image/webp", "webp"),
"image/gif" => image_format("image/gif", "gif"),
"image/jpeg" | "image/jpg" | "application/octet-stream" | "" => {
image_format(DEFAULT_IMAGE_MIME, "jpg")
}
_ => image_format(DEFAULT_IMAGE_MIME, "jpg"),
}
}
pub(crate) fn decode_generated_image_asset_data_url(
raw_data_url: &str,
) -> Result<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
let (metadata, encoded) = raw_data_url
.trim()
.split_once(',')
.ok_or(GeneratedImageAssetHelperError::InvalidDataUrl)?;
let metadata = metadata.trim();
if !metadata.to_ascii_lowercase().starts_with("data:") {
return Err(GeneratedImageAssetHelperError::InvalidDataUrl);
}
let header = &metadata["data:".len()..];
let mut parts = header
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty());
let mime_type = parts.next().unwrap_or(DEFAULT_IMAGE_MIME);
let is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64"));
if !is_base64 {
return Err(GeneratedImageAssetHelperError::UnsupportedEncoding);
}
let bytes = BASE64_STANDARD
.decode(encoded.trim())
.map_err(|error| GeneratedImageAssetHelperError::DecodeBase64(error.to_string()))?;
Ok(GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(mime_type),
bytes,
})
}
pub(crate) fn build_generated_image_asset_storage_paths(
prefix: LegacyAssetPrefix,
path_segments: &[String],
file_name: &str,
) -> Result<GeneratedImageAssetStoragePaths, GeneratedImageAssetHelperError> {
let file_name = sanitize_generated_image_asset_file_name(file_name)?;
let mut parts = vec![prefix.as_str().to_string()];
parts.extend(
path_segments
.iter()
.map(|segment| sanitize_generated_image_asset_path_segment(segment))
.filter(|segment| !segment.is_empty()),
);
parts.push(file_name);
let object_key = parts.join("/");
Ok(GeneratedImageAssetStoragePaths {
legacy_public_path: format!("/{object_key}"),
object_key,
})
}
pub(crate) fn build_generated_image_asset_metadata(
input: GeneratedImageAssetMetadataInput,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::new();
insert_optional_metadata(&mut metadata, "asset_kind", input.asset_kind);
insert_optional_metadata(&mut metadata, "owner_user_id", input.owner_user_id);
insert_optional_metadata(&mut metadata, "entity_kind", input.entity_kind);
insert_optional_metadata(&mut metadata, "entity_id", input.entity_id);
insert_optional_metadata(&mut metadata, "slot", input.slot);
insert_optional_metadata(&mut metadata, "provider", input.provider);
insert_optional_metadata(&mut metadata, "task_id", input.task_id);
metadata
}
pub(crate) fn merge_generated_image_asset_metadata(
base: BTreeMap<String, String>,
overlay: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
for (key, value) in base.into_iter().chain(overlay) {
let key = key.trim();
let value = value.trim();
if key.is_empty() || value.is_empty() {
continue;
}
merged.insert(key.to_string(), value.to_string());
}
merged
}
fn image_format(mime_type: &str, extension: &str) -> GeneratedImageAssetImageFormat {
GeneratedImageAssetImageFormat {
mime_type: mime_type.to_string(),
extension: extension.to_string(),
}
}
fn insert_optional_metadata(
metadata: &mut BTreeMap<String, String>,
key: &str,
value: Option<String>,
) {
if let Some(value) = value {
let value = value.trim();
if !value.is_empty() {
metadata.insert(key.to_string(), value.to_string());
}
}
}
fn sanitize_generated_image_asset_path_segment(raw: &str) -> String {
raw.trim()
.trim_matches('/')
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
ch if ch.is_control() => '-',
ch => ch,
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn sanitize_generated_image_asset_file_name(
raw: &str,
) -> Result<String, GeneratedImageAssetHelperError> {
let sanitized = sanitize_generated_image_asset_path_segment(raw);
if sanitized.is_empty() || sanitized == "." || sanitized == ".." || sanitized.contains('/') {
return Err(GeneratedImageAssetHelperError::InvalidFileName);
}
Ok(sanitized)
}
#[cfg(test)]
mod generated_image_assets_tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn generated_image_assets_normalize_mime_and_extension() {
assert_eq!(
normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "),
image_format("image/png", "png")
);
assert_eq!(
normalize_generated_image_asset_mime("image/jpg"),
image_format("image/jpeg", "jpg")
);
assert_eq!(
normalize_generated_image_asset_mime("text/plain"),
image_format("image/jpeg", "jpg")
);
}
#[test]
fn generated_image_assets_decode_data_url_base64() {
let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=")
.expect("data url should decode");
assert_eq!(decoded.format, image_format("image/webp", "webp"));
assert_eq!(decoded.bytes, b"hello");
}
#[test]
fn generated_image_assets_reject_non_base64_data_url() {
assert_eq!(
decode_generated_image_asset_data_url("data:image/png,hello").unwrap_err(),
GeneratedImageAssetHelperError::UnsupportedEncoding
);
}
#[test]
fn generated_image_assets_build_object_key_and_legacy_path() {
let paths = build_generated_image_asset_storage_paths(
LegacyAssetPrefix::BigFishAssets,
&[" world/001 ".to_string(), "slot:cover".to_string()],
" image.png ",
)
.expect("paths should build");
assert_eq!(
paths.object_key,
"generated-big-fish-assets/world-001/slot-cover/image.png"
);
assert_eq!(
paths.legacy_public_path,
"/generated-big-fish-assets/world-001/slot-cover/image.png"
);
}
#[test]
fn generated_image_assets_merge_metadata_trims_and_overlay_wins() {
let base = BTreeMap::from([
("asset_kind".to_string(), " old ".to_string()),
("empty".to_string(), " ".to_string()),
]);
let overlay = BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
(" task_id ".to_string(), " task-1 ".to_string()),
]);
assert_eq!(
merge_generated_image_asset_metadata(base, overlay),
BTreeMap::from([
("asset_kind".to_string(), "cover".to_string()),
("task_id".to_string(), "task-1".to_string()),
])
);
}
#[test]
fn generated_image_assets_build_metadata_omits_blank_values() {
let metadata = build_generated_image_asset_metadata(GeneratedImageAssetMetadataInput {
asset_kind: Some(" scene ".to_string()),
owner_user_id: Some("".to_string()),
entity_kind: Some("world".to_string()),
entity_id: None,
slot: Some(" cover ".to_string()),
provider: Some("dashscope".to_string()),
task_id: Some(" task-1 ".to_string()),
});
assert_eq!(metadata.get("asset_kind"), Some(&"scene".to_string()));
assert_eq!(metadata.get("owner_user_id"), None);
assert_eq!(metadata.get("slot"), Some(&"cover".to_string()));
assert_eq!(metadata.get("task_id"), Some(&"task-1".to_string()));
}
}

View File

@@ -0,0 +1,13 @@
// 中文注释C0 先落公共骨架,真实调用方迁移到 C1 后再移除未使用豁免。
#![allow(dead_code, unused_imports)]
pub mod adapter;
pub mod helpers;
pub(crate) use adapter::{GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary};
pub(crate) use helpers::{
GeneratedImageAssetDataUrl, GeneratedImageAssetImageFormat, GeneratedImageAssetMetadataInput,
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
build_generated_image_asset_storage_paths, decode_generated_image_asset_data_url,
merge_generated_image_asset_metadata, normalize_generated_image_asset_mime,
};

View File

@@ -35,6 +35,7 @@ mod custom_world_foundation_draft;
mod custom_world_result_prompts;
mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod generated_image_assets;
mod health;
mod http_error;
mod hyper3d_generation;
@@ -44,6 +45,7 @@ mod login_options;
mod logout;
mod logout_all;
mod match3d;
mod modules;
mod openai_image_generation;
mod password_entry;
mod password_management;

View File

@@ -2137,493 +2137,9 @@ async fn persist_match3d_generated_item_assets_snapshot(
.map(|_| ())
}
fn map_match3d_agent_session_response(
session: Match3DAgentSessionRecord,
) -> Match3DAgentSessionSnapshotResponse {
Match3DAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage.clone(),
anchor_pack: map_match3d_anchor_pack_response_for_turn(
session.anchor_pack,
session.current_turn,
session.stage.as_str(),
),
config: session.config.map(map_match3d_config_response),
draft: session.draft.map(map_match3d_draft_response),
messages: session
.messages
.into_iter()
.map(map_match3d_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
updated_at: session.updated_at,
}
}
mod mappers;
fn map_match3d_agent_session_response_with_assets(
session: Match3DAgentSessionRecord,
generated_item_assets: &[Match3DGeneratedItemAsset],
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect();
if draft
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
}
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
}
response
}
fn map_match3d_anchor_pack_response_for_turn(
anchor: Match3DAnchorPackRecord,
current_turn: u32,
stage: &str,
) -> Match3DAnchorPackResponse {
let is_ready = matches!(
stage,
"ReadyToCompile"
| "ready_to_compile"
| "DraftCompiled"
| "draft_compiled"
| "draft_ready"
| "ReadyToPublish"
| "ready_to_publish"
| "Published"
| "published"
);
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
Match3DAnchorPackResponse {
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
clear_count: map_match3d_anchor_item_response_for_collected(
anchor.clear_count,
collected_count >= 2,
),
difficulty: map_match3d_anchor_item_response_for_collected(
anchor.difficulty,
collected_count >= 3,
),
}
}
fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
fn map_match3d_anchor_item_response_for_collected(
anchor: Match3DAnchorItemRecord,
collected: bool,
) -> Match3DAnchorItemResponse {
if collected {
return map_match3d_anchor_item_response(anchor);
}
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: String::new(),
status: "missing".to_string(),
}
}
fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
Match3DCreatorConfigResponse {
theme_text: config.theme_text,
reference_image_src: config.reference_image_src,
clear_count: config.clear_count,
difficulty: config.difficulty,
asset_style_id: config.asset_style_id,
asset_style_label: config.asset_style_label,
asset_style_prompt: config.asset_style_prompt,
generate_click_sound: config.generate_click_sound,
}
}
fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
Match3DResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
summary_text: Some(draft.summary_text.clone()),
summary: draft.summary_text,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
reference_image_src: draft.reference_image_src,
clear_count: draft.clear_count,
difficulty: draft.difficulty,
total_item_count: draft.total_item_count,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
background_prompt: None,
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
}
}
fn map_match3d_generated_item_asset_for_agent(
asset: Match3DGeneratedItemAsset,
) -> Match3DAgentGeneratedItemAssetResponse {
Match3DAgentGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_agent)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_agent),
status: asset.status,
error: asset.error,
}
}
fn map_match3d_generated_item_asset_for_work(
asset: Match3DGeneratedItemAssetJson,
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_work)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_work),
status: asset.status,
error: asset.error,
}
}
fn map_match3d_image_view_for_agent(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
fn map_match3d_image_view_for_work(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
fn map_match3d_image_view_from_work(
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
) -> Match3DGeneratedItemImageView {
Match3DGeneratedItemImageView {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
fn map_match3d_background_asset_for_agent(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
fn map_match3d_background_asset_for_work(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
fn find_match3d_generated_background_asset(
assets: &[Match3DGeneratedItemAsset],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
find_match3d_generated_background_asset(assets).and_then(|asset| {
asset
.container_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
})
}
fn find_match3d_generated_background_asset_json(
assets: &[Match3DGeneratedItemAssetJson],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
fn apply_match3d_background_asset_to_agent_draft(
draft: &mut Match3DResultDraftResponse,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
) {
if let Some(asset) = background_asset {
draft.background_prompt = Some(asset.prompt.clone());
draft.background_image_src = asset.image_src.clone();
draft.background_image_object_key = asset.image_object_key.clone();
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
}
}
fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
Match3DAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generated_background_asset = background_asset
.clone()
.map(map_match3d_background_asset_for_work);
let generated_item_assets = generated_item_asset_json
.into_iter()
.map(map_match3d_generated_item_asset_for_work)
.collect();
Match3DWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
game_name: item.game_name,
theme_text: item.theme_text,
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
reference_image_src: item.reference_image_src,
clear_count: item.clear_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
play_count: item.play_count,
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset
.as_ref()
.and_then(|asset| asset.image_src.clone()),
background_image_object_key: background_asset
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
generated_background_asset,
generated_item_assets,
}
}
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
}))
}
fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
"missingAssets": ["背景音乐"],
}))
}
fn require_match3d_background_music_title(
plan: &Match3DGeneratedBackgroundMusicPlan,
) -> Result<String, AppError> {
let title = normalize_match3d_audio_title(plan.title.as_str());
if title.is_empty() {
return Err(match3d_background_music_missing_error(
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
));
}
Ok(title)
}
fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
Match3DWorkProfileResponse {
summary: map_match3d_work_summary_response(item),
}
}
fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
Match3DRunSnapshotResponse {
run_id: run.run_id,
profile_id: run.profile_id,
owner_user_id: run.owner_user_id,
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
snapshot_version: run.snapshot_version,
started_at_ms: run.started_at_ms,
duration_limit_ms: run.duration_limit_ms,
server_now_ms: run.server_now_ms,
remaining_ms: run.remaining_ms,
clear_count: run.clear_count,
total_item_count: run.total_item_count,
cleared_item_count: run.cleared_item_count,
items: run
.items
.into_iter()
.map(map_match3d_item_response)
.collect(),
tray_slots: run
.tray_slots
.into_iter()
.map(map_match3d_tray_slot_response)
.collect(),
failure_reason: run
.failure_reason
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
last_confirmed_action_id: run.last_confirmed_action_id,
}
}
fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
Match3DItemSnapshotResponse {
item_instance_id: item.item_instance_id,
item_type_id: item.item_type_id,
visual_key: item.visual_key,
x: item.x,
y: item.y,
radius: item.radius,
layer: item.layer,
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
clickable: item.clickable,
tray_slot_index: item.tray_slot_index,
}
}
fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
Match3DTraySlotResponse {
slot_index: slot.slot_index,
item_instance_id: slot.item_instance_id,
item_type_id: slot.item_type_id,
visual_key: slot.visual_key,
}
}
fn map_match3d_click_confirmation_response(
confirmation: Match3DClickConfirmationRecord,
) -> Match3DClickConfirmationResponse {
Match3DClickConfirmationResponse {
accepted: confirmation.accepted,
reject_reason: confirmation
.reject_reason
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
entered_slot_index: confirmation.entered_slot_index,
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
run: map_match3d_run_response(confirmation.run),
}
}
use mappers::*;
fn build_config_from_create_request(
payload: &CreateMatch3DAgentSessionRequest,
@@ -2861,175 +2377,9 @@ fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
.filter(|value| !value.is_empty())
}
fn normalize_match3d_tag(value: &str) -> String {
let trimmed = value.trim();
let without_number_prefix = trimmed
.char_indices()
.find_map(|(index, ch)| {
if index == 0 || !matches!(ch, '.' | '、' | ')' | '') {
return None;
}
let prefix = &trimmed[..index];
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
Some(trimmed[index + ch.len_utf8()..].trim_start())
} else {
None
}
})
.unwrap_or(trimmed);
mod tags;
without_number_prefix
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.collect::<String>()
.chars()
.take(6)
.collect::<String>()
}
fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_match3d_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= 6 {
break;
}
}
for fallback in ["抓大鹅", "经典消除", "2D素材", "轻量休闲", "收集", "挑战"] {
if tags.len() >= 6 {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
async fn generate_match3d_work_tags_for_profile(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Vec<String> {
request_match3d_work_tags_with_llm(state, game_name, theme_text, summary)
.await
.unwrap_or_else(|| fallback_match3d_work_tags(game_name, theme_text))
}
async fn request_match3d_work_tags_with_llm(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Option<Vec<String>> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return None;
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n作品描述:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text,
game_name,
summary.unwrap_or_default()
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = parse_match3d_tags_from_text(response.content.as_str());
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
return Some(tags);
}
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
"抓大鹅 AI 标签生成数量不足,降级使用本地标签"
);
None
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
None
}
}
}
async fn generate_match3d_work_tags_for_plan(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: &str,
plan_tags: &[String],
) -> Vec<String> {
if let Some(tags) =
request_match3d_work_tags_with_llm(state, game_name, theme_text, Some(summary)).await
{
return tags;
}
merge_match3d_plan_tags_with_fallback(game_name, theme_text, plan_tags)
}
fn merge_match3d_plan_tags_with_fallback(
game_name: &str,
theme_text: &str,
plan_tags: &[String],
) -> Vec<String> {
let mut candidates = plan_tags.to_vec();
candidates.extend(fallback_match3d_work_tags(game_name, theme_text));
normalize_match3d_tag_candidates(candidates)
}
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('[')
&& let Some(end) = raw.rfind(']')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
normalize_match3d_tag_candidates(parsed)
}
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
}
use tags::*;
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
if assets.is_empty() {
@@ -3614,9 +2964,8 @@ async fn ensure_match3d_background_music_asset(
));
};
let title = require_match3d_background_music_title(plan).map_err(|error| {
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
})?;
let title = require_match3d_background_music_title(plan)
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let style = normalize_match3d_audio_style(plan.style.as_str());
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
.await
@@ -6556,12 +5905,13 @@ mod tests {
#[test]
fn match3d_background_music_title_is_required_for_auto_draft() {
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
title: " ,。 ".to_string(),
style: "轻快, 休闲".to_string(),
prompt: String::new(),
})
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
let missing =
require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
title: " ,。 ".to_string(),
style: "轻快, 休闲".to_string(),
prompt: String::new(),
})
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
assert!(missing.body_text().contains("背景音乐"));

View File

@@ -0,0 +1,490 @@
use super::*;
pub(super) fn map_match3d_agent_session_response(
session: Match3DAgentSessionRecord,
) -> Match3DAgentSessionSnapshotResponse {
Match3DAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage.clone(),
anchor_pack: map_match3d_anchor_pack_response_for_turn(
session.anchor_pack,
session.current_turn,
session.stage.as_str(),
),
config: session.config.map(map_match3d_config_response),
draft: session.draft.map(map_match3d_draft_response),
messages: session
.messages
.into_iter()
.map(map_match3d_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
updated_at: session.updated_at,
}
}
pub(super) fn map_match3d_agent_session_response_with_assets(
session: Match3DAgentSessionRecord,
generated_item_assets: &[Match3DGeneratedItemAsset],
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect();
if draft
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
}
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
}
response
}
pub(super) fn map_match3d_anchor_pack_response_for_turn(
anchor: Match3DAnchorPackRecord,
current_turn: u32,
stage: &str,
) -> Match3DAnchorPackResponse {
let is_ready = matches!(
stage,
"ReadyToCompile"
| "ready_to_compile"
| "DraftCompiled"
| "draft_compiled"
| "draft_ready"
| "ReadyToPublish"
| "ready_to_publish"
| "Published"
| "published"
);
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
Match3DAnchorPackResponse {
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
clear_count: map_match3d_anchor_item_response_for_collected(
anchor.clear_count,
collected_count >= 2,
),
difficulty: map_match3d_anchor_item_response_for_collected(
anchor.difficulty,
collected_count >= 3,
),
}
}
pub(super) fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_match3d_anchor_item_response_for_collected(
anchor: Match3DAnchorItemRecord,
collected: bool,
) -> Match3DAnchorItemResponse {
if collected {
return map_match3d_anchor_item_response(anchor);
}
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: String::new(),
status: "missing".to_string(),
}
}
pub(super) fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
Match3DCreatorConfigResponse {
theme_text: config.theme_text,
reference_image_src: config.reference_image_src,
clear_count: config.clear_count,
difficulty: config.difficulty,
asset_style_id: config.asset_style_id,
asset_style_label: config.asset_style_label,
asset_style_prompt: config.asset_style_prompt,
generate_click_sound: config.generate_click_sound,
}
}
pub(super) fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
Match3DResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
summary_text: Some(draft.summary_text.clone()),
summary: draft.summary_text,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
reference_image_src: draft.reference_image_src,
clear_count: draft.clear_count,
difficulty: draft.difficulty,
total_item_count: draft.total_item_count,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
background_prompt: None,
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
}
}
pub(super) fn map_match3d_generated_item_asset_for_agent(
asset: Match3DGeneratedItemAsset,
) -> Match3DAgentGeneratedItemAssetResponse {
Match3DAgentGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_agent)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_agent),
status: asset.status,
error: asset.error,
}
}
pub(super) fn map_match3d_generated_item_asset_for_work(
asset: Match3DGeneratedItemAssetJson,
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_work)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_work),
status: asset.status,
error: asset.error,
}
}
pub(super) fn map_match3d_image_view_for_agent(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
pub(super) fn map_match3d_image_view_for_work(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
pub(super) fn map_match3d_image_view_from_work(
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
) -> Match3DGeneratedItemImageView {
Match3DGeneratedItemImageView {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
pub(super) fn map_match3d_background_asset_for_agent(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
pub(super) fn map_match3d_background_asset_for_work(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
pub(super) fn find_match3d_generated_background_asset(
assets: &[Match3DGeneratedItemAsset],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
pub(super) fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
find_match3d_generated_background_asset(assets).and_then(|asset| {
asset
.container_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
})
}
pub(super) fn find_match3d_generated_background_asset_json(
assets: &[Match3DGeneratedItemAssetJson],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
pub(super) fn apply_match3d_background_asset_to_agent_draft(
draft: &mut Match3DResultDraftResponse,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
) {
if let Some(asset) = background_asset {
draft.background_prompt = Some(asset.prompt.clone());
draft.background_image_src = asset.image_src.clone();
draft.background_image_object_key = asset.image_object_key.clone();
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
}
}
pub(super) fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
Match3DAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generated_background_asset = background_asset
.clone()
.map(map_match3d_background_asset_for_work);
let generated_item_assets = generated_item_asset_json
.into_iter()
.map(map_match3d_generated_item_asset_for_work)
.collect();
Match3DWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
game_name: item.game_name,
theme_text: item.theme_text,
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
reference_image_src: item.reference_image_src,
clear_count: item.clear_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
play_count: item.play_count,
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset
.as_ref()
.and_then(|asset| asset.image_src.clone()),
background_image_object_key: background_asset
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
generated_background_asset,
generated_item_assets,
}
}
pub(super) fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
}))
}
pub(super) fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
"missingAssets": ["背景音乐"],
}))
}
pub(super) fn require_match3d_background_music_title(
plan: &Match3DGeneratedBackgroundMusicPlan,
) -> Result<String, AppError> {
let title = normalize_match3d_audio_title(plan.title.as_str());
if title.is_empty() {
return Err(match3d_background_music_missing_error(
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
));
}
Ok(title)
}
pub(super) fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
Match3DWorkProfileResponse {
summary: map_match3d_work_summary_response(item),
}
}
pub(super) fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
Match3DRunSnapshotResponse {
run_id: run.run_id,
profile_id: run.profile_id,
owner_user_id: run.owner_user_id,
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
snapshot_version: run.snapshot_version,
started_at_ms: run.started_at_ms,
duration_limit_ms: run.duration_limit_ms,
server_now_ms: run.server_now_ms,
remaining_ms: run.remaining_ms,
clear_count: run.clear_count,
total_item_count: run.total_item_count,
cleared_item_count: run.cleared_item_count,
items: run
.items
.into_iter()
.map(map_match3d_item_response)
.collect(),
tray_slots: run
.tray_slots
.into_iter()
.map(map_match3d_tray_slot_response)
.collect(),
failure_reason: run
.failure_reason
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
last_confirmed_action_id: run.last_confirmed_action_id,
}
}
pub(super) fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
Match3DItemSnapshotResponse {
item_instance_id: item.item_instance_id,
item_type_id: item.item_type_id,
visual_key: item.visual_key,
x: item.x,
y: item.y,
radius: item.radius,
layer: item.layer,
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
clickable: item.clickable,
tray_slot_index: item.tray_slot_index,
}
}
pub(super) fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
Match3DTraySlotResponse {
slot_index: slot.slot_index,
item_instance_id: slot.item_instance_id,
item_type_id: slot.item_type_id,
visual_key: slot.visual_key,
}
}
pub(super) fn map_match3d_click_confirmation_response(
confirmation: Match3DClickConfirmationRecord,
) -> Match3DClickConfirmationResponse {
Match3DClickConfirmationResponse {
accepted: confirmation.accepted,
reject_reason: confirmation
.reject_reason
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
entered_slot_index: confirmation.entered_slot_index,
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
run: map_match3d_run_response(confirmation.run),
}
}

View File

@@ -0,0 +1,172 @@
use super::*;
pub(super) fn normalize_match3d_tag(value: &str) -> String {
let trimmed = value.trim();
let without_number_prefix = trimmed
.char_indices()
.find_map(|(index, ch)| {
if index == 0 || !matches!(ch, '.' | '、' | ')' | '') {
return None;
}
let prefix = &trimmed[..index];
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
Some(trimmed[index + ch.len_utf8()..].trim_start())
} else {
None
}
})
.unwrap_or(trimmed);
without_number_prefix
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.collect::<String>()
.chars()
.take(6)
.collect::<String>()
}
pub(super) fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_match3d_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= 6 {
break;
}
}
for fallback in ["抓大鹅", "经典消除", "2D素材", "轻量休闲", "收集", "挑战"] {
if tags.len() >= 6 {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
pub(super) async fn generate_match3d_work_tags_for_profile(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Vec<String> {
request_match3d_work_tags_with_llm(state, game_name, theme_text, summary)
.await
.unwrap_or_else(|| fallback_match3d_work_tags(game_name, theme_text))
}
pub(super) async fn request_match3d_work_tags_with_llm(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Option<Vec<String>> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return None;
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n作品描述:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text,
game_name,
summary.unwrap_or_default()
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = parse_match3d_tags_from_text(response.content.as_str());
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
return Some(tags);
}
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
"抓大鹅 AI 标签生成数量不足,降级使用本地标签"
);
None
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
None
}
}
}
pub(super) async fn generate_match3d_work_tags_for_plan(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: &str,
plan_tags: &[String],
) -> Vec<String> {
if let Some(tags) =
request_match3d_work_tags_with_llm(state, game_name, theme_text, Some(summary)).await
{
return tags;
}
merge_match3d_plan_tags_with_fallback(game_name, theme_text, plan_tags)
}
pub(super) fn merge_match3d_plan_tags_with_fallback(
game_name: &str,
theme_text: &str,
plan_tags: &[String],
) -> Vec<String> {
let mut candidates = plan_tags.to_vec();
candidates.extend(fallback_match3d_work_tags(game_name, theme_text));
normalize_match3d_tag_candidates(candidates)
}
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
pub(super) fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('[')
&& let Some(end) = raw.rfind(']')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
normalize_match3d_tag_candidates(parsed)
}
pub(super) fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
}

View File

@@ -0,0 +1,110 @@
use axum::{Router, middleware, routing::get};
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_login, admin_me,
admin_overview, admin_upsert_creation_entry_config, require_admin_auth,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/admin/api/login", axum::routing::post(admin_login))
.route(
"/admin/api/me",
get(admin_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/overview",
get(admin_overview).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
axum::routing::post(admin_debug_http).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/tracking/events",
get(admin_list_tracking_events).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/database/tables",
get(admin_list_database_tables).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/database/tables/{table_name}/rows",
get(admin_list_database_table_rows).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/creation-entry/config",
get(admin_get_creation_entry_config)
.post(admin_upsert_creation_entry_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)
.post(admin_upsert_profile_redeem_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes/disable",
axum::routing::post(admin_disable_profile_redeem_code).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/profile/invite-codes",
get(admin_list_profile_invite_codes)
.post(admin_upsert_profile_invite_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks",
get(admin_list_profile_task_configs)
.post(admin_upsert_profile_task_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks/disable",
axum::routing::post(admin_disable_profile_task_config)
.route_layer(middleware::from_fn_with_state(state, require_admin_auth)),
)
}

View File

@@ -0,0 +1,54 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
assets::{
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
create_sts_upload_credentials, get_asset_history, get_asset_read_bytes, get_asset_read_url,
},
auth::require_bearer_auth,
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/sts-upload-credentials",
post(create_sts_upload_credentials).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/objects/confirm",
post(confirm_asset_object).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/objects/bind",
post(bind_asset_object_to_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route("/api/assets/read-bytes", get(get_asset_read_bytes))
.route(
"/api/assets/history",
get(get_asset_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,111 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::{attach_refresh_session_token, require_bearer_auth},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::{auth_sessions, revoke_auth_session},
login_options::auth_login_options,
logout::logout,
logout_all::logout_all,
password_entry::password_entry,
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
refresh_session::refresh_session,
state::AppState,
wechat_auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
},
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/public-users/by-code/{code}",
get(get_public_user_by_code),
)
.route(
"/api/auth/public-users/by-id/{user_id}",
get(get_public_user_by_id),
)
.route(
"/api/auth/me",
get(auth_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions",
get(auth_sessions)
.route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/sessions/{session_id}/revoke",
post(revoke_auth_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/refresh",
post(refresh_session).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/miniprogram-login",
post(login_wechat_mini_program),
)
.route(
"/api/auth/wechat/bind-phone",
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/entry", post(password_entry))
.route(
"/api/auth/password/change",
post(change_password).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/password/reset", post(reset_password))
.route(
"/api/auth/logout",
post(logout)
.route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout-all",
post(logout_all).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,119 @@
use axum::{
Router, middleware,
routing::{delete, get, post},
};
use crate::{
auth::require_bearer_auth,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/big-fish/agent/sessions",
post(create_big_fish_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}",
get(get_big_fish_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}/messages",
post(submit_big_fish_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}/messages/stream",
post(stream_big_fish_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/agent/sessions/{session_id}/actions",
post(execute_big_fish_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works",
get(get_big_fish_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery))
.route(
"/api/runtime/big-fish/gallery/{session_id}/remix",
post(remix_big_fish_gallery_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/gallery/{session_id}/like",
post(record_big_fish_gallery_like).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works/{session_id}",
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/play",
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works/{session_id}/play",
post(record_big_fish_play).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}",
get(get_big_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/runs/{run_id}/input",
post(submit_big_fish_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,208 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
generate_custom_world_profile, get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_result_view,
get_custom_world_agent_session, get_custom_world_gallery_detail,
get_custom_world_gallery_detail_by_code, get_custom_world_library,
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
record_custom_world_gallery_like, record_custom_world_gallery_play,
remix_custom_world_gallery_profile, stream_custom_world_agent_message,
submit_custom_world_agent_message, unpublish_custom_world_library_profile,
},
custom_world_ai::{
generate_custom_world_cover_image, generate_custom_world_entity,
generate_custom_world_opening_cg, generate_custom_world_scene_image,
generate_custom_world_scene_npc, upload_custom_world_cover_image,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/custom-world-library",
get(get_custom_world_library).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}",
get(get_custom_world_library_detail)
.put(put_custom_world_library_profile)
.delete(delete_custom_world_library_profile)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}/publish",
post(publish_custom_world_library_profile).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-library/{profile_id}/unpublish",
post(unpublish_custom_world_library_profile).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world-gallery",
get(list_custom_world_gallery),
)
.route(
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
get(get_custom_world_gallery_detail),
)
.route(
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix",
post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play",
post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like",
post(record_custom_world_gallery_like).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world-gallery/by-code/{code}",
get(get_custom_world_gallery_detail_by_code),
)
.route(
"/api/runtime/custom-world/agent/sessions",
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}",
get(get_custom_world_agent_session)
.delete(delete_custom_world_agent_session)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/result-view",
get(get_custom_world_agent_result_view).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/works",
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages/stream",
post(stream_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/actions",
post(execute_custom_world_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}",
get(get_custom_world_agent_operation).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/profile",
post(generate_custom_world_profile).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/opening-cg",
post(generate_custom_world_opening_cg).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,7 @@
use axum::{Router, routing::get};
use crate::{health::health_check, state::AppState};
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/healthz", get(health_check))
}

View File

@@ -0,0 +1,27 @@
use axum::{Router, middleware, routing::get};
use crate::{
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/_internal/auth/claims",
get(inspect_auth_claims).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/_internal/auth/refresh-cookie",
get(inspect_refresh_session_cookie).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
}

View File

@@ -0,0 +1,173 @@
use axum::{
Router, middleware,
routing::{get, post, put},
};
use crate::{
auth::require_bearer_auth,
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
generate_match3d_background_image_for_work, generate_match3d_cover_image,
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/match3d/sessions",
post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}",
get(get_match3d_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/messages",
post(submit_match3d_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/messages/stream",
post(stream_match3d_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/actions",
post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/compile",
post(compile_match3d_agent_draft).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works",
get(get_match3d_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/tags",
post(generate_match3d_work_tags).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}",
get(get_match3d_work_detail)
.patch(put_match3d_work)
.put(put_match3d_work)
.delete(delete_match3d_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/audio-assets",
put(put_match3d_audio_assets).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/cover-image",
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/background-image",
post(generate_match3d_background_image_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/item-assets",
post(generate_match3d_item_assets_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/generated-models",
post(persist_match3d_generated_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/publish",
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/runtime/match3d/gallery", get(list_match3d_gallery))
.route(
"/api/runtime/match3d/works/{profile_id}/runs",
post(start_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}",
get(get_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/click",
post(click_match3d_item).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/stop",
post(stop_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/restart",
post(restart_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/time-up",
post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,13 @@
pub mod admin;
pub mod assets;
pub mod auth;
pub mod big_fish;
pub mod custom_world;
pub mod health;
pub mod internal;
pub mod match3d;
pub mod platform;
pub mod profile;
pub mod puzzle;
pub mod square_hole;
pub mod story;

View File

@@ -0,0 +1,293 @@
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
};
use crate::{
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
},
auth::require_bearer_auth,
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
import_character_animation_video, list_character_animation_templates,
publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow,
save_character_workflow_cache,
},
character_visual_assets::{
generate_character_visual, get_character_visual_job, publish_character_visual,
},
creation_agent_document_input::parse_creation_agent_document_input,
creation_entry_config::get_creation_entry_config_handler,
hyper3d_generation::{
get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model,
submit_hyper3d_text_to_model,
},
llm::proxy_llm_chat_completions,
runtime_chat::stream_runtime_npc_chat_turn,
runtime_chat_plain::{
generate_runtime_character_chat_suggestions, generate_runtime_character_chat_summary,
stream_runtime_character_chat_reply, stream_runtime_npc_chat_dialogue,
stream_runtime_npc_recruit_dialogue,
},
runtime_save::{delete_runtime_snapshot, get_runtime_snapshot, put_runtime_snapshot},
runtime_settings::{get_runtime_settings, put_runtime_settings},
state::AppState,
volcengine_speech::{
get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection,
stream_volcengine_tts_sse,
},
};
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/llm/chat/completions",
post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/speech/volcengine/config",
get(get_volcengine_speech_config).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/speech/volcengine/asr/stream",
get(stream_volcengine_asr).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/speech/volcengine/tts/bidirection",
get(stream_volcengine_tts_bidirection).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/speech/volcengine/tts/sse",
post(stream_volcengine_tts_sse).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/character/suggestions",
post(generate_runtime_character_chat_suggestions).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/chat/character/summary",
post(generate_runtime_character_chat_summary).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/chat/character/reply/stream",
post(stream_runtime_character_chat_reply).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/dialogue/stream",
post(stream_runtime_npc_chat_dialogue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/turn/stream",
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/recruit/stream",
post(stream_runtime_npc_recruit_dialogue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creation-agent/document-inputs/parse",
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks",
post(create_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/start",
post(start_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/start",
post(start_ai_task_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/chunks",
post(append_ai_text_chunk).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/stages/{stage_kind}/complete",
post(complete_ai_stage).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/references",
post(attach_ai_result_reference).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/complete",
post(complete_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/fail",
post(fail_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/ai/tasks/{task_id}/cancel",
post(cancel_ai_task).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/character-visual/generate",
post(generate_character_visual),
)
.route(
"/api/assets/character-visual/jobs/{task_id}",
get(get_character_visual_job),
)
.route(
"/api/assets/character-visual/publish",
post(publish_character_visual),
)
.route(
"/api/assets/character-animation/generate",
post(generate_character_animation),
)
.route(
"/api/assets/character-animation/jobs/{task_id}",
get(get_character_animation_job),
)
.route(
"/api/assets/character-animation/publish",
post(publish_character_animation),
)
.route(
"/api/assets/character-animation/import-video",
post(import_character_animation_video),
)
.route(
"/api/assets/character-animation/templates",
get(list_character_animation_templates),
)
.route(
"/api/assets/character-workflow-cache",
post(save_character_workflow_cache),
)
.route(
"/api/assets/character-workflow-cache/{character_id}",
get(get_character_workflow_cache),
)
.route(
"/api/runtime/custom-world/asset-studio/role/{character_id}/workflow",
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
)
.route(
"/api/assets/hyper3d/text-to-model",
post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/image-to-model",
post(submit_hyper3d_image_to_model)
.layer(DefaultBodyLimit::max(
HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/status",
post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/download",
post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation-entry/config",
get(get_creation_entry_config_handler),
)
.route(
"/api/runtime/settings",
get(get_runtime_settings)
.put(put_runtime_settings)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/save/snapshot",
get(get_runtime_snapshot)
.put(put_runtime_snapshot)
.delete(delete_runtime_snapshot)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,141 @@
use axum::{
Router, middleware,
routing::{get, patch, post},
};
use crate::{
auth::require_bearer_auth,
profile_identity::update_profile_identity,
runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
},
runtime_profile::{
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
},
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/profile/me",
patch(update_profile_identity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/browse-history",
get(get_runtime_browse_history)
.post(post_runtime_browse_history)
.delete(delete_runtime_browse_history)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/dashboard",
get(get_profile_dashboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/feedback",
post(submit_profile_feedback)
.layer(axum::extract::DefaultBodyLimit::max(6 * 1024 * 1024))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/redeem-code",
post(redeem_profile_referral_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/redeem-codes/redeem",
post(redeem_profile_reward_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/analytics/metric",
get(get_profile_analytics_metric).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/tasks",
get(get_profile_task_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/tasks/{task_id}/claim",
post(claim_profile_task_reward).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives/{world_key}",
post(resume_profile_save_archive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,184 @@
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
puzzle::{
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
generate_puzzle_onboarding_work, get_puzzle_agent_session, get_puzzle_gallery_detail,
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work,
save_puzzle_onboarding_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
update_puzzle_run_pause, use_puzzle_runtime_prop,
},
state::AppState,
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session)
// 中文注释:拼图表单会携带单张参考图 Data URL需只给该写入入口放宽 body 上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}",
get(get_puzzle_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}/messages",
post(submit_puzzle_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}/messages/stream",
post(stream_puzzle_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions/{session_id}/actions",
post(execute_puzzle_agent_action)
// 中文注释:生成草稿/重新出图会复用 referenceImageSrc避免默认 2MB JSON limit 拦截。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/onboarding/generate",
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
)),
)
.route(
"/api/runtime/puzzle/onboarding/save",
post(save_puzzle_onboarding_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works",
get(get_puzzle_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works/{profile_id}",
get(get_puzzle_work_detail)
.put(put_puzzle_work)
.delete(delete_puzzle_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works/{profile_id}/point-incentive/claim",
post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery))
.route(
"/api/runtime/puzzle/gallery/{profile_id}",
get(get_puzzle_gallery_detail),
)
.route(
"/api/runtime/puzzle/gallery/{profile_id}/remix",
post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/gallery/{profile_id}/like",
post(record_puzzle_gallery_like).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs",
post(start_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}",
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/swap",
post(swap_puzzle_pieces).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/drag",
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/next-level",
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/pause",
post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/props",
post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,142 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
square_hole::{
compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work,
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail,
get_square_hole_works, list_square_hole_gallery, publish_square_hole_work,
put_square_hole_work, regenerate_square_hole_work_image, restart_square_hole_run,
start_square_hole_run, stop_square_hole_run, stream_square_hole_agent_message,
submit_square_hole_agent_message,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/square-hole/sessions",
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}",
get(get_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/messages",
post(submit_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/messages/stream",
post(stream_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/actions",
post(execute_square_hole_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/compile",
post(compile_square_hole_agent_draft).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works",
get(get_square_hole_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}",
get(get_square_hole_work_detail)
.patch(put_square_hole_work)
.put(put_square_hole_work)
.delete(delete_square_hole_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}/publish",
post(publish_square_hole_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}/images/regenerate",
post(regenerate_square_hole_work_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/gallery",
get(list_square_hole_gallery),
)
.route(
"/api/runtime/square-hole/works/{profile_id}/runs",
post(start_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}",
get(get_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/drop",
post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/stop",
post(stop_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/restart",
post(restart_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/time-up",
post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,154 @@
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
creative_agent::{
cancel_creative_agent_session, confirm_creative_puzzle_template,
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
stream_creative_draft_edit,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
},
story_sessions::{
begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
},
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/story/sessions",
post(begin_story_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/runtime",
post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/state",
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/runtime-projection",
get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/{story_session_id}/actions/resolve",
post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/sessions/continue",
post(continue_story).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles",
post(create_story_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles/{battle_state_id}",
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/npc/battle",
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/story/battles/resolve",
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions",
post(create_creative_agent_session)
// 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL沿用拼图参考图入口上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}",
get(get_creative_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/messages/stream",
post(stream_creative_agent_message)
// 中文注释message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/confirm-template",
post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream",
post(stream_creative_draft_edit)
// 中文注释:草稿编辑会携带当前 puzzle draft JSON保持和拼图草稿入口一致的 body 上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/cancel",
post(cancel_creative_agent_session)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,529 @@
use super::*;
pub(super) fn map_puzzle_agent_session_response(
session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionSnapshotResponse {
PuzzleAgentSessionSnapshotResponse {
session_id: session.session_id,
seed_text: session.seed_text,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_puzzle_result_draft_response),
messages: session
.messages
.into_iter()
.map(map_puzzle_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
suggested_actions: session
.suggested_actions
.into_iter()
.map(map_puzzle_suggested_action_response)
.collect(),
result_preview: session
.result_preview
.map(map_puzzle_result_preview_response),
updated_at: session.updated_at,
}
}
pub(super) fn map_puzzle_anchor_pack_response(
anchor_pack: PuzzleAnchorPackRecord,
) -> PuzzleAnchorPackResponse {
PuzzleAnchorPackResponse {
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood),
composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks),
tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden),
}
}
pub(super) fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse {
PuzzleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse {
PuzzleResultDraftResponse {
work_title: draft.work_title,
work_description: draft.work_description,
level_name: draft.level_name,
summary: draft.summary,
theme_tags: draft.theme_tags,
forbidden_directives: draft.forbidden_directives,
creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response),
anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack),
candidates: draft
.candidates
.into_iter()
.map(map_puzzle_generated_image_candidate_response)
.collect(),
selected_candidate_id: draft.selected_candidate_id,
cover_image_src: draft.cover_image_src,
cover_asset_id: draft.cover_asset_id,
generation_status: draft.generation_status,
levels: draft
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect(),
form_draft: draft.form_draft.map(map_puzzle_form_draft_response),
}
}
pub(super) fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse {
PuzzleFormDraftResponse {
work_title: draft.work_title,
work_description: draft.work_description,
picture_description: draft.picture_description,
}
}
pub(super) fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse {
PuzzleDraftLevelResponse {
level_id: level.level_id,
level_name: level.level_name,
picture_description: level.picture_description,
picture_reference: level.picture_reference,
ui_background_prompt: level.ui_background_prompt,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
candidates: level
.candidates
.into_iter()
.map(map_puzzle_generated_image_candidate_response)
.collect(),
selected_candidate_id: level.selected_candidate_id,
cover_image_src: level.cover_image_src,
cover_asset_id: level.cover_asset_id,
generation_status: level.generation_status,
}
}
pub(super) fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset {
CreationAudioAsset {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
pub(super) fn map_puzzle_audio_asset_domain_record(
asset: module_puzzle::PuzzleAudioAsset,
) -> PuzzleAudioAssetRecord {
PuzzleAudioAssetRecord {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
pub(super) fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAudioAsset>) -> Value {
asset
.as_ref()
.map(|asset| {
json!({
"task_id": asset.task_id,
"provider": asset.provider,
"asset_object_id": asset.asset_object_id,
"asset_kind": asset.asset_kind,
"audio_src": asset.audio_src,
"prompt": asset.prompt,
"title": asset.title,
"updated_at": asset.updated_at,
})
})
.unwrap_or(Value::Null)
}
pub(super) fn puzzle_audio_asset_record_module_json(asset: &Option<PuzzleAudioAssetRecord>) -> Value {
asset
.as_ref()
.map(|asset| {
json!({
"task_id": asset.task_id,
"provider": asset.provider,
"asset_object_id": asset.asset_object_id,
"asset_kind": asset.asset_kind,
"audio_src": asset.audio_src,
"prompt": asset.prompt,
"title": asset.title,
"updated_at": asset.updated_at,
})
})
.unwrap_or(Value::Null)
}
pub(super) fn map_puzzle_creator_intent_response(
intent: PuzzleCreatorIntentRecord,
) -> PuzzleCreatorIntentResponse {
PuzzleCreatorIntentResponse {
source_mode: intent.source_mode,
raw_messages_summary: intent.raw_messages_summary,
theme_promise: intent.theme_promise,
visual_subject: intent.visual_subject,
visual_mood: intent.visual_mood,
composition_hooks: intent.composition_hooks,
theme_tags: intent.theme_tags,
forbidden_directives: intent.forbidden_directives,
}
}
pub(super) fn map_puzzle_generated_image_candidate_response(
candidate: PuzzleGeneratedImageCandidateRecord,
) -> PuzzleGeneratedImageCandidateResponse {
PuzzleGeneratedImageCandidateResponse {
candidate_id: candidate.candidate_id,
image_src: candidate.image_src,
asset_id: candidate.asset_id,
prompt: candidate.prompt,
actual_prompt: candidate.actual_prompt,
source_type: candidate.source_type,
selected: candidate.selected,
}
}
pub(super) fn map_puzzle_agent_message_response(
message: PuzzleAgentMessageRecord,
) -> PuzzleAgentMessageResponse {
PuzzleAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_puzzle_suggested_action_response(
action: PuzzleAgentSuggestedActionRecord,
) -> PuzzleAgentSuggestedActionResponse {
PuzzleAgentSuggestedActionResponse {
id: action.action_id,
action_type: action.action_type,
label: action.label,
}
}
pub(super) fn map_puzzle_result_preview_response(
preview: PuzzleResultPreviewRecord,
) -> PuzzleResultPreviewEnvelopeResponse {
PuzzleResultPreviewEnvelopeResponse {
draft: map_puzzle_result_draft_response(preview.draft),
blockers: preview
.blockers
.into_iter()
.map(map_puzzle_result_preview_blocker_response)
.collect(),
quality_findings: preview
.quality_findings
.into_iter()
.map(map_puzzle_result_preview_finding_response)
.collect(),
publish_ready: preview.publish_ready,
}
}
pub(super) fn map_puzzle_result_preview_blocker_response(
blocker: PuzzleResultPreviewBlockerRecord,
) -> PuzzleResultPreviewBlockerResponse {
PuzzleResultPreviewBlockerResponse {
id: blocker.blocker_id,
code: blocker.code,
message: blocker.message,
}
}
pub(super) fn map_puzzle_result_preview_finding_response(
finding: PuzzleResultPreviewFindingRecord,
) -> PuzzleResultPreviewFindingResponse {
PuzzleResultPreviewFindingResponse {
id: finding.finding_id,
severity: finding.severity,
code: finding.code,
message: finding.message,
}
}
pub(super) fn map_puzzle_work_summary_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.work_title,
work_description: item.work_description,
level_name: item.level_name,
summary: item.summary,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: item.publication_status,
updated_at: item.updated_at,
published_at: item.published_at,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: item.point_incentive_total_half_points,
point_incentive_claimed_points: item.point_incentive_claimed_points,
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
point_incentive_claimable_points: item
.point_incentive_total_half_points
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
levels: Vec::new(),
}
}
pub(super) fn map_puzzle_work_profile_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
summary.levels = item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect();
PuzzleWorkProfileResponse {
summary,
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
}
}
pub(super) fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
PuzzleRunSnapshotResponse {
run_id: run.run_id,
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids: run.played_profile_ids,
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
next_level_mode: run.next_level_mode,
next_level_profile_id: run.next_level_profile_id,
next_level_id: run.next_level_id,
recommended_next_works: run
.recommended_next_works
.into_iter()
.map(map_puzzle_recommended_next_work_response)
.collect(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
pub(super) fn map_puzzle_recommended_next_work_response(
item: PuzzleRecommendedNextWorkRecord,
) -> PuzzleRecommendedNextWorkResponse {
PuzzleRecommendedNextWorkResponse {
profile_id: item.profile_id,
level_name: item.level_name,
author_display_name: item.author_display_name,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
similarity_score: item.similarity_score,
}
}
pub(super) async fn enrich_puzzle_run_author_name(
state: &AppState,
mut run: PuzzleRunRecord,
) -> PuzzleRunRecord {
if let Some(level) = run.current_level.as_mut() {
if let Ok(profile) = state
.spacetime_client()
.get_puzzle_gallery_detail(level.profile_id.clone())
.await
{
level.author_display_name = resolve_work_author_by_user_id(
state,
&profile.owner_user_id,
Some(&profile.author_display_name),
None,
)
.display_name;
}
}
run
}
pub(super) fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
let timer_defaults =
build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size);
let time_limit_ms = if level.time_limit_ms == 0 {
timer_defaults.time_limit_ms
} else {
level.time_limit_ms
};
let remaining_ms =
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
time_limit_ms
} else {
level.remaining_ms.min(time_limit_ms)
};
PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id,
level_index: level.level_index,
level_id: level.level_id,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms,
remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
struct PuzzleRuntimeTimerResponseDefaults {
time_limit_ms: u64,
}
fn build_puzzle_runtime_timer_response_defaults(
level_index: u32,
grid_size: u32,
) -> PuzzleRuntimeTimerResponseDefaults {
let time_limit_ms = if level_index > 0 {
module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index)
} else {
module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size)
};
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
}
pub(super) fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
PuzzleLeaderboardEntryResponse {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
visible_tags: entry.visible_tags,
is_current_player: entry.is_current_player,
}
}
pub(super) fn map_puzzle_board_response(
board: spacetime_client::PuzzleBoardRecord,
) -> PuzzleBoardSnapshotResponse {
PuzzleBoardSnapshotResponse {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateResponse {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupStateResponse {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionResponse {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
pub(super) fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "拼图创作信息已准备好。".to_string();
}
"拼图创作信息已准备好。".to_string()
}

View File

@@ -0,0 +1,518 @@
use super::*;
pub(super) async fn generate_puzzle_work_tags(
state: &AppState,
work_title: &str,
work_description: &str,
) -> Vec<String> {
if let Some(llm_client) = state.llm_client() {
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
response.content.as_str(),
));
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
return tags;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
work_title,
"拼图 AI 标签数量不足,降级使用关键词补齐"
);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
work_title,
error = %error,
"拼图 AI 标签生成失败,降级使用关键词标签"
);
}
}
}
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
}
pub(super) fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
let trimmed = text.trim();
let json_text = if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
&trimmed[start..=end]
} else {
trimmed
};
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
return normalize_puzzle_tag_candidates(trimmed.split([',', '', '、', '\n']));
};
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
return Vec::new();
};
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
}
pub(super) fn normalize_puzzle_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_puzzle_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
break;
}
}
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
pub(super) fn normalize_puzzle_tag(value: &str) -> String {
value
.trim()
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ''))
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.take(6)
.collect::<String>()
}
pub(super) fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
let source = format!("{work_title} {work_description}");
let mut tags = Vec::new();
for (keyword, tag) in [
("", "猫咪"),
("", "小狗"),
("神庙", "神庙遗迹"),
("遗迹", "神庙遗迹"),
("森林", "童话森林"),
("", "雨夜"),
("", "夜景"),
("城市", "城市奇景"),
("蒸汽", "蒸汽城市"),
("机械", "机械幻想"),
("", "海岸"),
("", "花园"),
("", "雪景"),
("", "幻想生物"),
("", "暖灯"),
("", "高塔"),
] {
if source.contains(keyword) && !tags.contains(&tag) {
tags.push(tag);
}
}
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
tags
}
pub(super) async fn save_generated_puzzle_tags_to_session(
state: &AppState,
session_id: &str,
owner_user_id: &str,
payload: &ExecutePuzzleAgentActionRequest,
generated_tags: Vec<String>,
levels_json: Option<String>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
let draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
parse_puzzle_level_records_from_module_json(levels_json)?
} else {
draft.levels.clone()
};
if levels.is_empty() {
levels = draft.levels.clone();
}
let first_level = levels.first().cloned().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图草稿缺少可编辑关卡",
}))
})?;
let work_title = payload
.work_title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.work_title.as_str())
.to_string();
let work_description = payload
.work_description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.work_description.as_str())
.to_string();
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id: owner_user_id.to_string(),
work_title: work_title.clone(),
work_description: work_description.clone(),
level_name: first_level.level_name.clone(),
summary: work_description.clone(),
theme_tags: generated_tags.clone(),
cover_image_src: first_level.cover_image_src.clone(),
cover_asset_id: first_level.cover_asset_id.clone(),
levels_json,
updated_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)?;
Ok(apply_generated_puzzle_tags_to_session_snapshot(
session,
generated_tags,
work_title,
work_description,
levels,
now,
))
}
pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
generated_tags: Vec<String>,
work_title: String,
work_description: String,
levels: Vec<PuzzleDraftLevelRecord>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
draft.work_title = work_title;
draft.work_description = work_description.clone();
draft.summary = work_description;
draft.theme_tags = generated_tags;
draft.levels = levels;
sync_puzzle_primary_draft_fields_from_level(draft);
session.progress_percent = session.progress_percent.max(96);
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
"ready_to_publish".to_string()
} else {
"image_refining".to_string()
};
session.last_assistant_reply = Some("作品标签已生成。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
!draft.work_title.trim().is_empty()
&& !draft.work_description.trim().is_empty()
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
&& !draft.levels.is_empty()
&& draft.levels.iter().all(|level| {
!level.level_name.trim().is_empty()
&& level
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
})
}
pub(super) fn serialize_puzzle_level_records_for_module(
levels: &[PuzzleDraftLevelRecord],
) -> Result<String, AppError> {
let payload = levels
.iter()
.map(|level| {
json!({
"level_id": level.level_id,
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
"candidates": level
.candidates
.iter()
.map(|candidate| {
json!({
"candidate_id": candidate.candidate_id,
"image_src": candidate.image_src,
"asset_id": candidate.asset_id,
"prompt": candidate.prompt,
"actual_prompt": candidate.actual_prompt,
"source_type": candidate.source_type,
"selected": candidate.selected,
})
})
.collect::<Vec<_>>(),
"selected_candidate_id": level.selected_candidate_id,
"cover_image_src": level.cover_image_src,
"cover_asset_id": level.cover_asset_id,
"generation_status": level.generation_status,
})
})
.collect::<Vec<_>>();
serde_json::to_string(&payload).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图关卡列表序列化失败:{error}"),
}))
})
}
pub(super) fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
matches!(
error.status_code(),
StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT
)
}
pub(super) fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
value: &str,
field_name: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(puzzle_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": format!("{field_name} is required"),
})),
));
}
Ok(())
}
pub(super) fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
puzzle_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": message,
})),
)
}
pub(super) fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
error if should_skip_asset_operation_billing_for_connectivity(error) => {
StatusCode::SERVICE_UNAVAILABLE
}
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
pub(super) fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool {
is_freeze_time && error.body_text().contains("操作不合法")
}
pub(super) fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
matches!(error, SpacetimeClientError::Procedure(message) if
message.contains("save_puzzle_form_draft")
&& (message.contains("No such procedure")
|| message.contains("不存在")
|| message.contains("does not exist")
|| message.contains("not found")))
}
pub(super) fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
// 中文注释:历史运行态或旧 SpacetimeDB 错误快照可能仍带 APIMart 图片网关文案;当前 GPT-image-2 已统一迁移到 VectorEngine返回给前端前先归一避免误导排障。
let is_legacy_apimart_image_error =
message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART");
let provider = if message.contains("VectorEngine")
|| message.contains("vector-engine")
|| message.contains("VECTOR_ENGINE")
|| is_legacy_apimart_image_error
{
VECTOR_ENGINE_PROVIDER
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
"puzzle-assets"
} else {
"spacetimedb"
};
let status = if provider == VECTOR_ENGINE_PROVIDER
&& (message.contains("VECTOR_ENGINE_API_KEY")
|| message.contains("VECTOR_ENGINE_BASE_URL")
|| message.contains("APIMART_API_KEY")
|| message.contains("APIMART_BASE_URL")
|| message.contains("未配置"))
{
StatusCode::SERVICE_UNAVAILABLE
} else if matches!(
error,
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout
) || should_skip_asset_operation_billing_for_connectivity(&error)
{
StatusCode::SERVICE_UNAVAILABLE
} else if matches!(error, SpacetimeClientError::Runtime(_))
&& (message.contains("生成")
|| message.contains("上游")
|| message.contains("VectorEngine")
|| message.contains("vector-engine")
|| message.contains("VECTOR_ENGINE")
|| is_legacy_apimart_image_error
|| message.contains("参考图")
|| message.contains("图片")
|| message.contains("OSS")
|| message.contains("oss"))
{
StatusCode::BAD_GATEWAY
} else {
match &error {
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
}
};
let user_message = normalize_legacy_puzzle_image_error_message(message.as_str());
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": user_message,
}))
}
pub(super) fn normalize_legacy_puzzle_image_error_message(message: &str) -> String {
message
.replace(
"APIMart 图片生成密钥未配置",
"VectorEngine 图片生成密钥未配置",
)
.replace(
"APIMart 图片生成地址未配置",
"VectorEngine 图片生成地址未配置",
)
.replace("APIMART_API_KEY", "VECTOR_ENGINE_API_KEY")
.replace("APIMART_BASE_URL", "VECTOR_ENGINE_BASE_URL")
}
pub(super) fn puzzle_error_response(
request_context: &RequestContext,
provider: &str,
error: AppError,
) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_str(provider)
.unwrap_or_else(|_| header::HeaderValue::from_static("puzzle")),
);
response
}
pub(super) fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})
}
pub(super) fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match puzzle_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
pub(super) fn puzzle_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,315 @@
use super::*;
pub(super) fn map_square_hole_agent_session_response(
session: SquareHoleAgentSessionRecord,
) -> SquareHoleSessionSnapshotResponse {
SquareHoleSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage.clone(),
anchor_pack: map_square_hole_anchor_pack_response_for_turn(
session.anchor_pack,
session.current_turn,
session.stage.as_str(),
),
config: map_square_hole_config_response(session.config),
draft: session.draft.map(map_square_hole_draft_response),
messages: session
.messages
.into_iter()
.map(map_square_hole_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
updated_at: session.updated_at,
}
}
pub(super) fn map_square_hole_anchor_pack_response_for_turn(
anchor: SquareHoleAnchorPackRecord,
current_turn: u32,
stage: &str,
) -> SquareHoleAnchorPackResponse {
let is_ready = matches!(
stage,
"ReadyToCompile"
| "ready_to_compile"
| "DraftCompiled"
| "draft_compiled"
| "draft_ready"
| "Published"
| "published"
);
let collected_count = if is_ready { 4 } else { current_turn.min(4) };
SquareHoleAnchorPackResponse {
theme: map_square_hole_anchor_item_response_for_collected(
anchor.theme,
collected_count >= 1,
),
twist_rule: map_square_hole_anchor_item_response_for_collected(
anchor.twist_rule,
collected_count >= 2,
),
shape_count: map_square_hole_anchor_item_response_for_collected(
anchor.shape_count,
collected_count >= 3,
),
difficulty: map_square_hole_anchor_item_response_for_collected(
anchor.difficulty,
collected_count >= 4,
),
}
}
pub(super) fn map_square_hole_anchor_item_response(
anchor: SquareHoleAnchorItemRecord,
) -> SquareHoleAnchorItemResponse {
SquareHoleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_square_hole_anchor_item_response_for_collected(
anchor: SquareHoleAnchorItemRecord,
collected: bool,
) -> SquareHoleAnchorItemResponse {
if collected {
return map_square_hole_anchor_item_response(anchor);
}
SquareHoleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: String::new(),
status: "missing".to_string(),
}
}
pub(super) fn map_square_hole_config_response(
config: SquareHoleCreatorConfigRecord,
) -> SquareHoleCreatorConfigResponse {
SquareHoleCreatorConfigResponse {
theme_text: config.theme_text,
twist_rule: config.twist_rule,
shape_count: config.shape_count,
difficulty: config.difficulty,
shape_options: config
.shape_options
.into_iter()
.map(map_square_hole_shape_option_response)
.collect(),
hole_options: config
.hole_options
.into_iter()
.map(map_square_hole_hole_option_response)
.collect(),
background_prompt: config.background_prompt,
cover_image_src: config.cover_image_src,
background_image_src: config.background_image_src,
}
}
pub(super) fn map_square_hole_draft_response(
draft: SquareHoleResultDraftRecord,
) -> SquareHoleResultDraftResponse {
SquareHoleResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
twist_rule: draft.twist_rule,
summary: draft.summary,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
background_prompt: draft.background_prompt,
background_image_src: draft.background_image_src,
shape_options: draft
.shape_options
.into_iter()
.map(map_square_hole_shape_option_response)
.collect(),
hole_options: draft
.hole_options
.into_iter()
.map(map_square_hole_hole_option_response)
.collect(),
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
}
}
pub(super) fn map_square_hole_message_response(
message: SquareHoleAgentMessageRecord,
) -> SquareHoleAgentMessageResponse {
SquareHoleAgentMessageResponse {
id: message.id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_square_hole_work_summary_response(
item: SquareHoleWorkProfileRecord,
) -> SquareHoleWorkSummaryResponse {
SquareHoleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
game_name: item.game_name,
theme_text: item.theme_text,
twist_rule: item.twist_rule,
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
background_prompt: item.background_prompt,
background_image_src: item.background_image_src,
shape_options: item
.shape_options
.into_iter()
.map(map_square_hole_work_shape_option_response)
.collect(),
hole_options: item
.hole_options
.into_iter()
.map(map_square_hole_work_hole_option_response)
.collect(),
shape_count: item.shape_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
play_count: item.play_count,
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
}
}
pub(super) fn map_square_hole_work_profile_response(
item: SquareHoleWorkProfileRecord,
) -> SquareHoleWorkProfileResponse {
SquareHoleWorkProfileResponse {
summary: map_square_hole_work_summary_response(item),
}
}
pub(super) fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapshotResponse {
SquareHoleRunSnapshotResponse {
run_id: run.run_id,
profile_id: run.profile_id,
owner_user_id: run.owner_user_id,
status: normalize_square_hole_run_status(run.status.as_str()).to_string(),
snapshot_version: run.snapshot_version,
started_at_ms: run.started_at_ms,
duration_limit_ms: run.duration_limit_ms,
remaining_ms: run.remaining_ms,
total_shape_count: run.total_shape_count,
completed_shape_count: run.completed_shape_count,
combo: run.combo,
best_combo: run.best_combo,
score: run.score,
rule_label: run.rule_label,
background_image_src: run.background_image_src,
current_shape: run.current_shape.map(map_square_hole_shape_response),
holes: run
.holes
.into_iter()
.map(map_square_hole_hole_response)
.collect(),
last_feedback: run.last_feedback.map(map_square_hole_feedback_response),
}
}
pub(super) fn map_square_hole_shape_response(
item: SquareHoleShapeSnapshotRecord,
) -> SquareHoleShapeSnapshotResponse {
SquareHoleShapeSnapshotResponse {
shape_id: item.shape_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
color: item.color,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_hole_response(
slot: SquareHoleHoleSnapshotRecord,
) -> SquareHoleHoleSnapshotResponse {
SquareHoleHoleSnapshotResponse {
hole_id: slot.hole_id,
hole_kind: slot.hole_kind,
label: slot.label,
x: slot.x,
y: slot.y,
image_src: slot.image_src,
}
}
pub(super) fn map_square_hole_shape_option_response(
item: SquareHoleShapeOptionRecord,
) -> SquareHoleShapeOptionResponse {
SquareHoleShapeOptionResponse {
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_hole_option_response(
item: SquareHoleHoleOptionRecord,
) -> SquareHoleHoleOptionResponse {
SquareHoleHoleOptionResponse {
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_work_shape_option_response(
item: SquareHoleShapeOptionRecord,
) -> SquareHoleWorkShapeOptionResponse {
SquareHoleWorkShapeOptionResponse {
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_work_hole_option_response(
item: SquareHoleHoleOptionRecord,
) -> SquareHoleWorkHoleOptionResponse {
SquareHoleWorkHoleOptionResponse {
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
pub(super) fn map_square_hole_feedback_response(
feedback: SquareHoleDropFeedbackRecord,
) -> SquareHoleDropFeedbackResponse {
SquareHoleDropFeedbackResponse {
accepted: feedback.accepted,
reject_reason: feedback.reject_reason,
message: feedback.message,
}
}

View File

@@ -0,0 +1,686 @@
use super::*;
pub(super) async fn generate_square_hole_visual_assets_for_session(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
regenerate_visual_assets: bool,
visual_asset_slot: Option<String>,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleAgentSessionRecord, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.get_square_hole_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let profile_id = session
.draft
.as_ref()
.map(|draft| draft.profile_id.clone())
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
"square hole 草稿尚未编译,不能生成图片资产",
)
})?;
let mut work = state
.spacetime_client()
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
visual_asset_slot.as_deref(),
visual_asset_option_id.as_deref(),
);
let cover_image_src = match work.cover_image_src.clone() {
Some(value)
if !should_generate_square_hole_cover_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
),
};
let background_image_src = match work.background_image_src.clone() {
Some(value)
if !should_generate_square_hole_background_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
),
};
let mut shape_options = work.shape_options.clone();
let prompt_work = work.clone();
for option in shape_options.iter_mut() {
if !should_generate_square_hole_shape_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
);
}
let mut hole_options = work.hole_options.clone();
for option in hole_options.iter_mut() {
if !should_generate_square_hole_hole_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
);
}
work = state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id: owner_user_id.clone(),
game_name: work.game_name.clone(),
theme_text: work.theme_text.clone(),
twist_rule: work.twist_rule.clone(),
summary_text: work.summary.clone(),
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
.unwrap_or_default(),
cover_image_src: cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let mut next_session = state
.spacetime_client()
.get_square_hole_agent_session(session_id, owner_user_id)
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
if let Some(draft) = next_session.draft.as_mut() {
draft.cover_image_src = work.cover_image_src.clone();
draft.background_image_src = work.background_image_src.clone();
draft.background_prompt = work.background_prompt.clone();
draft.shape_options = work.shape_options.clone();
draft.hole_options = work.hole_options.clone();
}
Ok(next_session)
}
pub(super) async fn regenerate_square_hole_visual_asset_for_work(
state: &AppState,
request_context: &RequestContext,
owner_user_id: String,
profile_id: String,
visual_asset_slot: String,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleWorkProfileRecord, Response> {
let mut work = state
.spacetime_client()
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
Some(visual_asset_slot.as_str()),
visual_asset_option_id.as_deref(),
)
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"图片槽位不存在",
)
})?;
let synthetic_session_id = work
.source_session_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| profile_id.clone());
let prompt_work = work.clone();
match &requested_slot {
SquareHoleVisualAssetSlotRequest::Cover => {
work.cover_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Background => {
work.background_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Shape(option_id) => {
let Some(option) = work
.shape_options
.iter_mut()
.find(|option| option.option_id == *option_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"形状图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Hole(hole_id) => {
let Some(option) = work
.hole_options
.iter_mut()
.find(|option| option.hole_id == *hole_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"洞口图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
}
state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id,
game_name: work.game_name.clone(),
theme_text: work.theme_text.clone(),
twist_rule: work.twist_rule.clone(),
summary_text: work.summary.clone(),
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
.unwrap_or_default(),
cover_image_src: work.cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: work.background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})
}
async fn generate_square_hole_image_data_url(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
prompt: &str,
size: &str,
failure_context: &str,
) -> Result<String, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt,
Some(build_square_hole_negative_prompt().as_str()),
size,
1,
&[],
failure_context,
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("{failure_context}:上游未返回图片"),
}))
})?;
let fallback_data_url = format_square_hole_data_url(&image);
match persist_square_hole_generated_asset(
state,
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
{
Ok(image_src) => Ok(image_src),
Err(error) => {
tracing::warn!(
provider = "square-hole-assets",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
message = %error.body_text(),
"方洞图片已生成但资产持久化失败,降级回写 Data URL"
);
Ok(fallback_data_url)
}
}
}
fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String {
format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(&image.bytes)
)
}
#[allow(clippy::too_many_arguments)]
async fn persist_square_hole_generated_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
task_id: &str,
image: DownloadedOpenAiImage,
generated_at_micros: i64,
) -> Result<String, 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 storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec![
sanitize_square_hole_asset_segment(session_id, "session"),
sanitize_square_hole_asset_segment(profile_id, "profile"),
sanitize_square_hole_asset_segment(asset_kind, "asset"),
storage_slot.clone(),
format!("asset-{generated_at_micros}"),
],
file_stem: "image".to_string(),
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
bytes: image.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(SQUARE_HOLE_ENTITY_KIND.to_string()),
entity_id: Some(profile_id.to_string()),
slot: Some(slot.to_string()),
provider: Some("openai".to_string()),
task_id: Some(task_id.to_string()),
},
extra_metadata: BTreeMap::from([("profile_id".to_string(), profile_id.to_string())]),
})
.map_err(map_square_hole_generated_image_asset_error)?;
let persisted_mime_type = prepared.format.mime_type.clone();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(map_square_hole_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_square_hole_asset_oss_error)?;
match state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
Ok(asset_object) => {
if let Err(error) = state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
SQUARE_HOLE_ENTITY_KIND.to_string(),
profile_id.to_string(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产绑定失败,历史素材索引可能缺少绑定记录"
);
}
}
Err(error) => {
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产对象确认失败,历史素材索引可能缺少本次记录"
);
}
}
Ok(put_result.legacy_public_path)
}
fn map_square_hole_generated_image_asset_error(
error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError,
) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备方洞图片资产上传请求失败:{error:?}"),
}))
}
fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "square-hole-assets",
"message": error.to_string(),
}))
}
fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
fallback.to_string()
} else {
sanitized
}
}
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
format!(
"移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。",
clean_prompt_text(&work.theme_text, "奇怪形状"),
clean_prompt_text(&work.twist_rule, "反直觉分拣")
)
}
fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String {
let custom_prompt = work.background_prompt.trim();
if !custom_prompt.is_empty() {
return format!(
"移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。",
custom_prompt
);
}
format!(
"移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。",
clean_prompt_text(&work.theme_text, "奇怪形状")
)
}
fn build_square_hole_shape_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleShapeOptionRecord,
) -> String {
let image_prompt = option.image_prompt.trim();
let option_prompt = if image_prompt.is_empty() {
format!("{} 主题的 {}", work.theme_text, option.label)
} else {
image_prompt.to_string()
};
format!(
"单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。",
clean_prompt_text(&option.label, "形状"),
clean_prompt_text(&option_prompt, "主题图案")
)
}
fn build_square_hole_hole_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleHoleOptionRecord,
) -> String {
let image_prompt = option.image_prompt.trim();
let option_prompt = if image_prompt.is_empty() {
format!("{} 主题的 {}", work.theme_text, option.label)
} else {
image_prompt.to_string()
};
format!(
"单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。",
clean_prompt_text(&option.label, "洞口"),
clean_prompt_text(&option_prompt, "主题洞口")
)
}
fn build_square_hole_negative_prompt() -> String {
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
}