Merge remote-tracking branch 'origin/codex/unified-creation-flow-phase1'
# Conflicts: # server-rs/crates/api-server/src/wooden_fish.rs
This commit is contained in:
@@ -28,6 +28,9 @@ use shared_contracts::admin::{
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{
|
||||
@@ -291,6 +294,7 @@ fn map_admin_creation_entry_type_config(
|
||||
category_label: entry.category_label,
|
||||
category_sort_order: entry.category_sort_order,
|
||||
updated_at_micros: entry.updated_at_micros,
|
||||
unified_creation_spec: entry.unified_creation_spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +309,23 @@ fn validate_admin_creation_entry_config(
|
||||
if title.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空"));
|
||||
}
|
||||
let unified_creation_spec = match payload.unified_creation_spec {
|
||||
Some(spec) => {
|
||||
validate_unified_creation_spec_for_play(&id, &spec).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
|
||||
})?;
|
||||
Some(spec)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let unified_creation_spec_json = unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| {
|
||||
encode_unified_creation_spec_response(spec).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(module_runtime::CreationEntryTypeAdminUpsertInput {
|
||||
id,
|
||||
title,
|
||||
@@ -317,6 +338,7 @@ fn validate_admin_creation_entry_config(
|
||||
category_id: payload.category_id.trim().to_string(),
|
||||
category_label: payload.category_label.trim().to_string(),
|
||||
category_sort_order: payload.category_sort_order,
|
||||
unified_creation_spec_json,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,14 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||
execute_billable_match3d_draft_generation(
|
||||
let compile_session_id = session_id.clone();
|
||||
let compile_owner_user_id = owner_user_id.clone();
|
||||
let compile_profile_id = profile_id.clone();
|
||||
let compile_initial_game_name = initial_game_name.clone();
|
||||
let compile_requested_summary = requested_summary.clone();
|
||||
let compile_initial_tags = initial_tags.clone();
|
||||
let compile_requested_cover_image_src = requested_cover_image_src.clone();
|
||||
let result = execute_billable_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
@@ -307,7 +314,108 @@ pub(super) async fn compile_match3d_draft_for_session(
|
||||
Ok((next_session, generated_item_assets))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(response) = result.as_ref()
|
||||
&& response.status().is_server_error()
|
||||
{
|
||||
let failure_message = match3d_response_failure_message(response);
|
||||
persist_failed_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
compile_session_id,
|
||||
compile_owner_user_id,
|
||||
compile_profile_id,
|
||||
compile_initial_game_name,
|
||||
compile_requested_summary,
|
||||
compile_initial_tags,
|
||||
compile_requested_cover_image_src,
|
||||
failure_message,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn persist_failed_match3d_draft_generation(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
game_name: String,
|
||||
summary: Option<String>,
|
||||
tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
failure_message: String,
|
||||
) {
|
||||
let failure_assets_json = serialize_match3d_failed_generation_assets(failure_message.as_str());
|
||||
if let Err(persist_error) = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
Some(game_name),
|
||||
summary.or_else(|| Some(String::new())),
|
||||
Some(serde_json::to_string(&tags).unwrap_or_default()),
|
||||
cover_image_src,
|
||||
None,
|
||||
failure_assets_json,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
status = ?persist_error.status(),
|
||||
"抓大鹅草稿生成失败后的状态回写失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_match3d_failed_generation_assets(message: &str) -> Option<String> {
|
||||
let background_asset = Match3DGeneratedBackgroundAsset {
|
||||
prompt: String::new(),
|
||||
status: "failed".to_string(),
|
||||
error: Some(message.trim().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let assets = vec![Match3DGeneratedItemAssetJson {
|
||||
item_id: "match3d-generation-failure".to_string(),
|
||||
item_name: "生成失败".to_string(),
|
||||
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image_views: Vec::new(),
|
||||
model_src: None,
|
||||
model_object_key: None,
|
||||
model_file_name: None,
|
||||
task_uuid: None,
|
||||
subscription_key: None,
|
||||
sound_prompt: None,
|
||||
background_music_title: None,
|
||||
background_music_style: None,
|
||||
background_music_prompt: None,
|
||||
background_music: None,
|
||||
click_sound: None,
|
||||
background_asset: Some(background_asset),
|
||||
status: "failed".to_string(),
|
||||
error: Some(message.trim().to_string()),
|
||||
}];
|
||||
serde_json::to_string(&assets).ok()
|
||||
}
|
||||
|
||||
fn match3d_response_failure_message(response: &Response) -> String {
|
||||
response
|
||||
.extensions()
|
||||
.get::<String>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("抓大鹅草稿生成失败,HTTP {}", response.status()))
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
|
||||
@@ -453,6 +453,32 @@ fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -
|
||||
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||
}
|
||||
|
||||
fn match3d_asset_status_is_failure(status: &str) -> bool {
|
||||
let normalized = status.trim().to_ascii_lowercase().replace(['-', ' '], "_");
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"failed" | "failure" | "error" | "partial_failed"
|
||||
)
|
||||
}
|
||||
|
||||
fn match3d_error_present(value: Option<&String>) -> bool {
|
||||
value.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn match3d_item_asset_has_failure(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||
match3d_asset_status_is_failure(asset.status.as_str())
|
||||
|| match3d_error_present(asset.error.as_ref())
|
||||
|| asset.background_asset.as_ref().is_some_and(|background| {
|
||||
match3d_asset_status_is_failure(background.status.as_str())
|
||||
|| match3d_error_present(background.error.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_background_asset_has_failure(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||
match3d_asset_status_is_failure(asset.status.as_str())
|
||||
|| match3d_error_present(asset.error.as_ref())
|
||||
}
|
||||
|
||||
fn resolve_match3d_work_generation_status(
|
||||
item: &Match3DWorkProfileRecord,
|
||||
assets: &[Match3DGeneratedItemAssetJson],
|
||||
@@ -462,6 +488,21 @@ fn resolve_match3d_work_generation_status(
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
let has_failure = assets.iter().any(match3d_item_asset_has_failure)
|
||||
|| background_asset.is_some_and(match3d_background_asset_has_failure);
|
||||
if has_failure {
|
||||
let has_partial_result = assets.iter().any(match3d_item_asset_has_image)
|
||||
|| background_asset.is_some_and(match3d_background_asset_has_image);
|
||||
return Some(
|
||||
if has_partial_result {
|
||||
"partial_failed"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if assets.is_empty()
|
||||
|| !assets.iter().any(match3d_item_asset_has_image)
|
||||
|| !background_asset.is_some_and(match3d_background_asset_has_image)
|
||||
|
||||
@@ -1842,3 +1842,45 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
|
||||
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_summary_marks_failed_generated_assets_failed() {
|
||||
let assets = vec![Match3DGeneratedItemAsset {
|
||||
background_asset: Some(Match3DGeneratedBackgroundAsset {
|
||||
prompt: "水果厨房背景".to_string(),
|
||||
status: "failed".to_string(),
|
||||
error: Some("VectorEngine 请求失败".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
status: "failed".to_string(),
|
||||
error: Some("VectorEngine 请求失败".to_string()),
|
||||
..test_match3d_generated_item_asset(1, "草莓")
|
||||
}];
|
||||
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
|
||||
work_id: "match3d-profile-1".to_string(),
|
||||
profile_id: "match3d-profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: Some("match3d-session-1".to_string()),
|
||||
author_display_name: "玩家".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string()],
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
reference_image_src: None,
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
publication_status: "draft".to_string(),
|
||||
play_count: 0,
|
||||
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
|
||||
published_at: None,
|
||||
publish_ready: false,
|
||||
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
response.generation_status.as_deref(),
|
||||
Some("partial_failed")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -555,6 +555,10 @@ impl AppState {
|
||||
.to_string(),
|
||||
category_sort_order: 0,
|
||||
updated_at_micros: 0,
|
||||
unified_creation_spec:
|
||||
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(
|
||||
creation_type_id,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,26 +147,34 @@ pub async fn execute_wooden_fish_action(
|
||||
wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
maybe_generate_hit_object_asset(
|
||||
let result = execute_wooden_fish_action_with_generated_assets(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
owner_user_id.as_str(),
|
||||
&owner_user_id,
|
||||
&author_display_name,
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
maybe_generate_hit_sound_asset(&mut payload);
|
||||
let response = state
|
||||
.spacetime_client()
|
||||
.execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
&request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})?;
|
||||
.await;
|
||||
if result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|response| response.status().is_server_error())
|
||||
&& matches!(
|
||||
payload.action_type,
|
||||
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
|
||||
)
|
||||
{
|
||||
mark_wooden_fish_generation_failed(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
owner_user_id.as_str(),
|
||||
author_display_name.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let response = result?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
@@ -372,16 +380,24 @@ async fn build_wooden_fish_draft(
|
||||
payload: &WoodenFishWorkspaceCreateRequest,
|
||||
state: &AppState,
|
||||
) -> Result<WoodenFishDraftResponse, Response> {
|
||||
Ok(WoodenFishDraftResponse {
|
||||
let work_title = resolve_wooden_fish_work_title(
|
||||
state,
|
||||
&payload.work_description,
|
||||
&payload.hit_object_prompt,
|
||||
)
|
||||
.await?;
|
||||
Ok(build_wooden_fish_draft_response(payload, work_title))
|
||||
}
|
||||
|
||||
fn build_wooden_fish_draft_response(
|
||||
payload: &WoodenFishWorkspaceCreateRequest,
|
||||
work_title: String,
|
||||
) -> WoodenFishDraftResponse {
|
||||
WoodenFishDraftResponse {
|
||||
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
|
||||
template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(),
|
||||
profile_id: None,
|
||||
work_title: resolve_wooden_fish_work_title(
|
||||
state,
|
||||
&payload.work_description,
|
||||
&payload.hit_object_prompt,
|
||||
)
|
||||
.await?,
|
||||
work_title,
|
||||
work_description: payload.work_description.trim().to_string(),
|
||||
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
||||
hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT),
|
||||
@@ -401,7 +417,7 @@ async fn build_wooden_fish_draft(
|
||||
.or_else(|| Some(default_wooden_fish_hit_sound_asset())),
|
||||
cover_image_src: None,
|
||||
generation_status: WoodenFishGenerationStatus::Draft,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_workspace_request(
|
||||
@@ -543,6 +559,62 @@ async fn maybe_generate_hit_object_asset(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_wooden_fish_action_with_generated_assets(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
payload: &mut WoodenFishActionRequest,
|
||||
) -> Result<shared_contracts::wooden_fish::WoodenFishActionResponse, Response> {
|
||||
maybe_generate_hit_object_asset(state, request_context, session_id, owner_user_id, payload)
|
||||
.await?;
|
||||
maybe_generate_hit_sound_asset(payload);
|
||||
state
|
||||
.spacetime_client()
|
||||
.execute_wooden_fish_action(
|
||||
session_id.to_string(),
|
||||
owner_user_id.to_string(),
|
||||
author_display_name.to_string(),
|
||||
payload.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn mark_wooden_fish_generation_failed(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
) {
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.mark_wooden_fish_generation_failed(
|
||||
session_id.to_string(),
|
||||
owner_user_id.to_string(),
|
||||
author_display_name.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
provider = WOODEN_FISH_CREATION_PROVIDER,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
request_id = request_context.request_id(),
|
||||
error = %error,
|
||||
"敲木鱼草稿生成失败后的状态回写失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
|
||||
WoodenFishImageAsset {
|
||||
asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user