feat: unify phase one creation flow
This commit is contained in:
@@ -1052,6 +1052,7 @@ mod tests {
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -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(),
|
||||
@@ -1475,7 +1547,8 @@ mod tests {
|
||||
floating_words: vec![],
|
||||
};
|
||||
|
||||
let draft = build_wooden_fish_draft(&payload);
|
||||
let draft =
|
||||
build_wooden_fish_draft_response(&payload, WOODEN_FISH_TEMPLATE_NAME.to_string());
|
||||
|
||||
assert!(draft.hit_sound_prompt.is_none());
|
||||
let asset = draft
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::errors::RuntimeProfileFieldError;
|
||||
use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse, build_phase1_unified_creation_spec,
|
||||
};
|
||||
|
||||
pub fn build_creation_entry_config_response(
|
||||
@@ -39,19 +39,23 @@ pub fn build_creation_entry_config_response(
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(|item| CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
.map(|item| {
|
||||
let unified_creation_spec = build_phase1_unified_creation_spec(item.id.as_str());
|
||||
CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
||||
@@ -51,4 +51,113 @@ pub struct CreationEntryTypeResponse {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationSpecResponse {
|
||||
pub play_id: String,
|
||||
pub title: String,
|
||||
pub workspace_stage: String,
|
||||
pub generation_stage: String,
|
||||
pub result_stage: String,
|
||||
pub fields: Vec<UnifiedCreationFieldResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationFieldResponse {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
|
||||
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
|
||||
"puzzle" => (
|
||||
"puzzle-agent-workspace",
|
||||
"puzzle-generating",
|
||||
"puzzle-result",
|
||||
vec![
|
||||
unified_creation_field("pictureDescription", "text", "画面描述", true),
|
||||
unified_creation_field("referenceImage", "image", "拼图画面", false),
|
||||
unified_creation_field("promptReferenceImages", "image", "参考图", false),
|
||||
],
|
||||
),
|
||||
"match3d" => (
|
||||
"match3d-agent-workspace",
|
||||
"match3d-generating",
|
||||
"match3d-result",
|
||||
vec![
|
||||
unified_creation_field("themeText", "text", "题材", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
],
|
||||
),
|
||||
"wooden-fish" => (
|
||||
"wooden-fish-workspace",
|
||||
"wooden-fish-generating",
|
||||
"wooden-fish-result",
|
||||
vec![
|
||||
unified_creation_field("hitObjectPrompt", "text", "敲什么", false),
|
||||
unified_creation_field("hitObjectReferenceImage", "image", "参考图", false),
|
||||
unified_creation_field("hitSoundAsset", "audio", "敲击音效", false),
|
||||
unified_creation_field("floatingWords", "text", "功德有什么", true),
|
||||
],
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(UnifiedCreationSpecResponse {
|
||||
play_id: play_id.to_string(),
|
||||
title: "想做个什么玩法?".to_string(),
|
||||
workspace_stage: workspace_stage.to_string(),
|
||||
generation_stage: generation_stage.to_string(),
|
||||
result_stage: result_stage.to_string(),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
fn unified_creation_field(
|
||||
id: &str,
|
||||
kind: &str,
|
||||
label: &str,
|
||||
required: bool,
|
||||
) -> UnifiedCreationFieldResponse {
|
||||
UnifiedCreationFieldResponse {
|
||||
id: id.to_string(),
|
||||
kind: kind.to_string(),
|
||||
label: label.to_string(),
|
||||
required,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn phase1_unified_creation_specs_only_cover_three_templates() {
|
||||
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
|
||||
assert_eq!(puzzle.fields[0].id, "pictureDescription");
|
||||
assert_eq!(puzzle.fields[1].kind, "image");
|
||||
|
||||
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
|
||||
assert_eq!(
|
||||
match3d
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|field| field.kind == "select")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let wooden_fish =
|
||||
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
|
||||
assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio"));
|
||||
assert!(build_phase1_unified_creation_spec("visual-novel").is_none());
|
||||
assert!(build_phase1_unified_creation_spec("bark-battle").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ fn map_wooden_fish_session_snapshot(
|
||||
fn map_wooden_fish_work_snapshot(
|
||||
snapshot: WoodenFishWorkSnapshot,
|
||||
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
|
||||
let generation_status = parse_generation_status(&snapshot.generation_status);
|
||||
let draft = WoodenFishDraftResponse {
|
||||
template_id: "wooden-fish".to_string(),
|
||||
template_name: "敲木鱼".to_string(),
|
||||
@@ -116,15 +117,23 @@ fn map_wooden_fish_work_snapshot(
|
||||
back_button_asset: snapshot.back_button_asset.clone().map(map_image_asset),
|
||||
hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset),
|
||||
cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()),
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
generation_status: generation_status.clone(),
|
||||
};
|
||||
let hit_object_asset = draft
|
||||
.hit_object_asset
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
matches!(generation_status, WoodenFishGenerationStatus::Failed)
|
||||
.then(default_failed_hit_object_asset)
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?;
|
||||
let hit_sound_asset = draft
|
||||
.hit_sound_asset
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
matches!(generation_status, WoodenFishGenerationStatus::Failed)
|
||||
.then(default_failed_hit_sound_asset)
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit sound asset"))?;
|
||||
Ok(WoodenFishWorkProfileResponse {
|
||||
summary: WoodenFishWorkSummaryResponse {
|
||||
@@ -143,7 +152,7 @@ fn map_wooden_fish_work_snapshot(
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
|
||||
publish_ready: snapshot.publish_ready,
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
generation_status,
|
||||
},
|
||||
draft,
|
||||
hit_object_asset,
|
||||
@@ -154,6 +163,31 @@ fn map_wooden_fish_work_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn default_failed_hit_object_asset() -> WoodenFishImageAsset {
|
||||
WoodenFishImageAsset {
|
||||
asset_id: "wooden-fish-failed-hit-object".to_string(),
|
||||
image_src: "/wooden-fish/default-hit-object.png".to_string(),
|
||||
image_object_key: "public/wooden-fish/default-hit-object.png".to_string(),
|
||||
asset_object_id: "wooden-fish-failed-hit-object".to_string(),
|
||||
generation_provider: "failed-fallback".to_string(),
|
||||
prompt: "生成失败占位图".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_failed_hit_sound_asset() -> WoodenFishAudioAsset {
|
||||
WoodenFishAudioAsset {
|
||||
asset_id: "wooden-fish-failed-hit-sound".to_string(),
|
||||
audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
asset_object_id: "wooden-fish-failed-hit-sound".to_string(),
|
||||
source: "failed-fallback".to_string(),
|
||||
prompt: Some("生成失败占位音效".to_string()),
|
||||
duration_ms: Some(3_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFishDraftResponse {
|
||||
WoodenFishDraftResponse {
|
||||
template_id: snapshot.template_id,
|
||||
|
||||
@@ -122,6 +122,35 @@ impl SpacetimeClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mark_wooden_fish_generation_failed(
|
||||
&self,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
author_display_name: String,
|
||||
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
|
||||
let current = self
|
||||
.get_wooden_fish_session(session_id.clone(), owner_user_id.clone())
|
||||
.await?;
|
||||
let mut draft = current.draft.clone().unwrap_or_else(default_draft);
|
||||
let profile_id = resolve_wooden_fish_profile_id(
|
||||
&draft,
|
||||
&WoodenFishActionType::CompileDraft,
|
||||
draft.profile_id.as_deref(),
|
||||
)?;
|
||||
draft.profile_id = Some(profile_id.clone());
|
||||
draft.generation_status = WoodenFishGenerationStatus::Failed;
|
||||
let now_micros = current_unix_micros();
|
||||
self.compile_wooden_fish_draft(build_failed_compile_input(
|
||||
¤t,
|
||||
&owner_user_id,
|
||||
&author_display_name,
|
||||
&profile_id,
|
||||
&draft,
|
||||
now_micros,
|
||||
)?)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compile_wooden_fish_draft(
|
||||
&self,
|
||||
procedure_input: WoodenFishDraftCompileInput,
|
||||
@@ -636,6 +665,52 @@ fn build_compile_input(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_failed_compile_input(
|
||||
current: &WoodenFishSessionSnapshotResponse,
|
||||
owner_user_id: &str,
|
||||
author_display_name: &str,
|
||||
profile_id: &str,
|
||||
draft: &WoodenFishDraftResponse,
|
||||
now_micros: i64,
|
||||
) -> Result<WoodenFishDraftCompileInput, SpacetimeClientError> {
|
||||
Ok(WoodenFishDraftCompileInput {
|
||||
session_id: current.session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
author_display_name: author_display_name.trim().to_string(),
|
||||
work_title: draft.work_title.clone(),
|
||||
work_description: draft.work_description.clone(),
|
||||
theme_tags_json: Some(json_string(&draft.theme_tags)?),
|
||||
hit_object_prompt: draft.hit_object_prompt.clone(),
|
||||
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
|
||||
hit_sound_prompt: draft.hit_sound_prompt.clone(),
|
||||
hit_object_asset_json: draft
|
||||
.hit_object_asset
|
||||
.as_ref()
|
||||
.map(json_string)
|
||||
.transpose()?,
|
||||
background_asset_json: draft
|
||||
.background_asset
|
||||
.as_ref()
|
||||
.map(json_string)
|
||||
.transpose()?,
|
||||
hit_sound_asset_json: draft
|
||||
.hit_sound_asset
|
||||
.as_ref()
|
||||
.map(json_string)
|
||||
.transpose()?,
|
||||
back_button_asset_json: draft
|
||||
.back_button_asset
|
||||
.as_ref()
|
||||
.map(json_string)
|
||||
.transpose()?,
|
||||
floating_words_json: Some(json_string(&draft.floating_words)?),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
generation_status: Some("failed".to_string()),
|
||||
compiled_at_micros: now_micros,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_update_input(
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
@@ -801,6 +876,7 @@ mod tests {
|
||||
|
||||
const SESSION_ID: &str = "wooden-fish-session-test";
|
||||
const OWNER_USER_ID: &str = "user-test";
|
||||
const AUTHOR_DISPLAY_NAME: &str = "测试玩家";
|
||||
const PROFILE_ID: &str = "wooden-fish-profile-test";
|
||||
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
||||
|
||||
@@ -813,9 +889,14 @@ mod tests {
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
|
||||
let (plan, draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
.expect("compile-draft should build plan");
|
||||
let (plan, draft) = build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("compile-draft should build plan");
|
||||
|
||||
let WoodenFishActionProcedure::Compile(input) = plan else {
|
||||
panic!("compile-draft should call compile_wooden_fish_draft");
|
||||
@@ -862,11 +943,16 @@ mod tests {
|
||||
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let error = match build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
) {
|
||||
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
@@ -883,11 +969,16 @@ mod tests {
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
Ok(_) => panic!("compile-draft should not publish without background asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let error = match build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
) {
|
||||
Ok(_) => panic!("compile-draft should not publish without background asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
@@ -904,11 +995,16 @@ mod tests {
|
||||
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
Ok(_) => panic!("compile-draft should not publish without back button asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let error = match build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
) {
|
||||
Ok(_) => panic!("compile-draft should not publish without back button asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
@@ -926,9 +1022,14 @@ mod tests {
|
||||
payload.background_asset = Some(generated_background_asset("generated-background"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
|
||||
|
||||
let (plan, _draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
.expect("regenerate-hit-object should build plan");
|
||||
let (plan, _draft) = build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("regenerate-hit-object should build plan");
|
||||
|
||||
let WoodenFishActionProcedure::Compile(input) = plan else {
|
||||
panic!("regenerate-hit-object should call compile_wooden_fish_draft");
|
||||
@@ -987,9 +1088,14 @@ mod tests {
|
||||
"健康+1".to_string(),
|
||||
]);
|
||||
|
||||
let (plan, draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
.expect("update-floating-words should build plan");
|
||||
let (plan, draft) = build_wooden_fish_action_plan(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
AUTHOR_DISPLAY_NAME,
|
||||
&payload,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("update-floating-words should build plan");
|
||||
|
||||
let WoodenFishActionProcedure::Update(input) = plan else {
|
||||
panic!("update-floating-words should call update_wooden_fish_work");
|
||||
@@ -1016,6 +1122,31 @@ mod tests {
|
||||
assert!(draft.hit_sound_prompt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_failed_compile_input_preserves_session_and_marks_failed() {
|
||||
let session = session_with_draft(draft_without_assets());
|
||||
let mut draft = session.draft.clone().expect("draft should exist");
|
||||
draft.profile_id = Some(PROFILE_ID.to_string());
|
||||
draft.generation_status = WoodenFishGenerationStatus::Failed;
|
||||
|
||||
let input = build_failed_compile_input(
|
||||
&session,
|
||||
OWNER_USER_ID,
|
||||
"测试玩家",
|
||||
PROFILE_ID,
|
||||
&draft,
|
||||
NOW_MICROS,
|
||||
)
|
||||
.expect("failed compile input should build");
|
||||
|
||||
assert_eq!(input.session_id, SESSION_ID);
|
||||
assert_eq!(input.profile_id, PROFILE_ID);
|
||||
assert_eq!(input.generation_status.as_deref(), Some("failed"));
|
||||
assert!(input.hit_object_asset_json.is_none());
|
||||
assert!(input.background_asset_json.is_none());
|
||||
assert!(input.back_button_asset_json.is_none());
|
||||
}
|
||||
|
||||
fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest {
|
||||
WoodenFishActionRequest {
|
||||
action_type,
|
||||
|
||||
@@ -534,6 +534,9 @@ fn publish_wooden_fish_work_tx(
|
||||
input: WoodenFishWorkPublishInput,
|
||||
) -> Result<WoodenFishWorkSnapshot, String> {
|
||||
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||||
if row.generation_status == WOODEN_FISH_GENERATION_FAILED {
|
||||
return Err("生成失败的敲木鱼作品需要重新生成后才能发布".to_string());
|
||||
}
|
||||
if !is_publish_ready(&row) {
|
||||
return Err("发布需要完整的敲击物图案、背景、返回按钮、敲击音效和飘字配置".to_string());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const WOODEN_FISH_PUBLICATION_PUBLISHED: &str = "Published";
|
||||
pub const WOODEN_FISH_GENERATION_DRAFT: &str = "draft";
|
||||
pub const WOODEN_FISH_GENERATION_GENERATING: &str = "generating";
|
||||
pub const WOODEN_FISH_GENERATION_READY: &str = "ready";
|
||||
pub const WOODEN_FISH_GENERATION_FAILED: &str = "failed";
|
||||
pub const WOODEN_FISH_EVENT_RUN_STARTED: &str = "run-started";
|
||||
pub const WOODEN_FISH_EVENT_RUN_CHECKPOINT: &str = "checkpoint";
|
||||
pub const WOODEN_FISH_EVENT_RUN_FINISHED: &str = "finish";
|
||||
|
||||
Reference in New Issue
Block a user