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:
kdletters
2026-06-01 15:22:58 +08:00
86 changed files with 4944 additions and 967 deletions

View File

@@ -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,
})
}

View File

@@ -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 泥点。

View File

@@ -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)

View File

@@ -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")
);
}

View File

@@ -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,
),
},
);
}

View File

@@ -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(),

View File

@@ -12,6 +12,7 @@ use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
};
pub fn build_creation_entry_config_response(
@@ -39,19 +40,26 @@ 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 = resolve_unified_creation_spec_response(
item.id.as_str(),
item.unified_creation_spec_json.as_deref(),
);
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(),
}
@@ -260,9 +268,15 @@ fn build_default_creation_entry_type_snapshot(
category_label: category_label.to_string(),
category_sort_order,
updated_at_micros,
unified_creation_spec_json: default_unified_creation_spec_json(id),
}
}
pub fn default_unified_creation_spec_json(play_id: &str) -> Option<String> {
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(play_id)
.and_then(|spec| encode_unified_creation_spec_response(&spec).ok())
}
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
RuntimeSettingsRecord {
user_id: snapshot.user_id,

View File

@@ -103,6 +103,7 @@ pub struct CreationEntryTypeSnapshot {
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
pub unified_creation_spec_json: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -130,6 +131,7 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub unified_creation_spec_json: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::creation_entry_config::UnifiedCreationSpecResponse;
// 管理后台协议统一收口在 shared-contracts避免页面脚本和 Rust handler 各自手拼字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -34,6 +36,8 @@ pub struct AdminCreationEntryTypeConfigPayload {
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>,
}
/// 后台保存创作入口开关配置请求。
@@ -51,6 +55,8 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
/// 后台作品可见性列表项。

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -51,4 +52,223 @@ 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, Eq)]
#[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, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationFieldResponse {
pub id: String,
pub kind: String,
pub label: String,
pub required: bool,
}
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
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),
],
),
"jump-hop" => (
"jump-hop-workspace",
"jump-hop-generating",
"jump-hop-result",
vec![
unified_creation_field("workTitle", "text", "作品标题", true),
unified_creation_field("workDescription", "text", "作品简介", true),
unified_creation_field("themeTags", "text", "主题标签", true),
unified_creation_field("difficulty", "select", "难度", true),
unified_creation_field("stylePreset", "select", "风格", true),
unified_creation_field("characterPrompt", "text", "角色提示词", true),
unified_creation_field("tilePrompt", "text", "地块提示词", true),
unified_creation_field("endMoodPrompt", "text", "终点氛围", false),
],
),
"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,
})
}
pub fn validate_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> {
if spec.play_id.trim().is_empty() {
return Err("统一创作契约 playId 不能为空".to_string());
}
if spec.title.trim().is_empty() {
return Err("统一创作契约标题不能为空".to_string());
}
let workspace_stage = spec.workspace_stage.trim();
let generation_stage = spec.generation_stage.trim();
let result_stage = spec.result_stage.trim();
if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() {
return Err("统一创作契约阶段不能为空".to_string());
}
if workspace_stage == generation_stage
|| workspace_stage == result_stage
|| generation_stage == result_stage
{
return Err("统一创作契约阶段不能重复".to_string());
}
if spec.fields.is_empty() {
return Err("统一创作契约 fields 不能为空".to_string());
}
let mut field_ids = BTreeSet::new();
for field in &spec.fields {
let field_id = field.id.trim();
if field_id.is_empty() {
return Err("统一创作契约字段 id 不能为空".to_string());
}
if !field_ids.insert(field_id.to_string()) {
return Err(format!("统一创作契约字段 id 重复:{field_id}"));
}
if field.label.trim().is_empty() {
return Err(format!("统一创作契约字段 {field_id} 标签不能为空"));
}
if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) {
return Err(format!(
"统一创作契约字段 {field_id} kind 非法:{}",
field.kind
));
}
}
Ok(())
}
pub fn validate_unified_creation_spec_for_play(
play_id: &str,
spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> {
if spec.play_id.trim() != play_id.trim() {
return Err(format!(
"统一创作契约 playId 必须与入口 ID 一致:{}",
play_id.trim()
));
}
validate_unified_creation_spec_response(spec)
}
pub fn encode_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse,
) -> Result<String, String> {
validate_unified_creation_spec_response(spec)?;
serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}"))
}
pub fn decode_unified_creation_spec_response(
value: &str,
) -> Result<UnifiedCreationSpecResponse, String> {
let spec = serde_json::from_str::<UnifiedCreationSpecResponse>(value)
.map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?;
validate_unified_creation_spec_response(&spec)?;
Ok(spec)
}
pub fn resolve_unified_creation_spec_response(
play_id: &str,
value: Option<&str>,
) -> Option<UnifiedCreationSpecResponse> {
match value {
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
None => build_phase1_unified_creation_spec(play_id),
}
}
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_cover_four_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 jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
assert!(jump_hop.fields.iter().any(|field| field.id == "stylePreset"));
assert!(jump_hop.fields.iter().any(|field| field.id == "endMoodPrompt"));
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());
}
}

View File

@@ -14,6 +14,7 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
category_id: input.category_id,
category_label: input.category_label,
category_sort_order: input.category_sort_order,
unified_creation_spec_json: input.unified_creation_spec_json,
}
}
}
@@ -299,6 +300,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
),
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: item.unified_creation_spec_json,
})
.collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
@@ -345,6 +347,7 @@ fn map_creation_entry_config_snapshot(
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
unified_creation_spec_json: item.unified_creation_spec_json,
})
.collect(),
updated_at_micros: snapshot.updated_at_micros,

View File

@@ -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,

View File

@@ -18,6 +18,7 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub unified_creation_spec_json: Option<String>,
}
impl __sdk::InModule for CreationEntryTypeAdminUpsertInput {

View File

@@ -19,6 +19,7 @@ pub struct CreationEntryTypeConfig {
pub category_id: Option<String>,
pub category_label: Option<String>,
pub category_sort_order: i32,
pub unified_creation_spec_json: Option<String>,
}
impl __sdk::InModule for CreationEntryTypeConfig {
@@ -41,6 +42,8 @@ pub struct CreationEntryTypeConfigCols {
pub category_id: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
pub category_label: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
pub category_sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
pub unified_creation_spec_json:
__sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
}
impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
@@ -62,6 +65,10 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
table_name,
"category_sort_order",
),
unified_creation_spec_json: __sdk::__query_builder::Col::new(
table_name,
"unified_creation_spec_json",
),
}
}
}

View File

@@ -19,6 +19,7 @@ pub struct CreationEntryTypeSnapshot {
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
pub unified_creation_spec_json: Option<String>,
}
impl __sdk::InModule for CreationEntryTypeSnapshot {

View File

@@ -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(
&current,
&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,

View File

@@ -11,6 +11,7 @@ crate-type = ["cdylib"]
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
module-ai = { workspace = true, features = ["spacetime-types"] }
module-assets = { workspace = true, features = ["spacetime-types"] }
module-bark-battle = { workspace = true }

View File

@@ -1184,7 +1184,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "creation_entry_type_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。
// 中文注释:入口分类和统一创作契约字段晚于入口类型配置表加入,旧迁移包按空配置兼容。
object
.entry("category_id".to_string())
.or_insert(serde_json::Value::Null);
@@ -1194,6 +1194,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("category_sort_order".to_string())
.or_insert_with(|| serde_json::Value::from(0));
object
.entry("unified_creation_spec_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "user_account" {

View File

@@ -46,6 +46,8 @@ pub struct CreationEntryTypeConfig {
pub(crate) category_label: Option<String>,
#[default(0)]
pub(crate) category_sort_order: i32,
#[default(None::<String>)]
pub(crate) unified_creation_spec_json: Option<String>,
}
#[spacetimedb::procedure]
@@ -96,6 +98,7 @@ fn upsert_creation_entry_type_config_in_tx(
if input.title.trim().is_empty() {
return Err("入口标题不能为空".to_string());
}
let unified_creation_spec_json = normalize_unified_creation_spec_json(&id, &input)?;
let row = CreationEntryTypeConfig {
id: id.clone(),
title: input.title.trim().to_string(),
@@ -109,6 +112,7 @@ fn upsert_creation_entry_type_config_in_tx(
category_id: Some(normalize_category_id(&input.category_id)),
category_label: Some(normalize_category_label(&input.category_label)),
category_sort_order: input.category_sort_order,
unified_creation_spec_json,
};
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
ctx.db.creation_entry_type_config().id().update(row);
@@ -145,6 +149,7 @@ fn get_or_seed_creation_entry_config_snapshot(
category_label: normalize_optional_category_label(row.category_label.as_deref()),
category_sort_order: row.category_sort_order,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: row.unified_creation_spec_json,
})
.collect::<Vec<_>>();
creation_types.sort_by(|left, right| {
@@ -404,10 +409,29 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
category_id: Some(snapshot.category_id),
category_label: Some(snapshot.category_label),
category_sort_order: snapshot.category_sort_order,
unified_creation_spec_json: snapshot.unified_creation_spec_json,
})
.collect()
}
fn normalize_unified_creation_spec_json(
id: &str,
input: &CreationEntryTypeAdminUpsertInput,
) -> Result<Option<String>, String> {
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
return Ok(None);
};
let normalized = spec_json.trim();
if normalized.is_empty() {
return Ok(None);
}
let spec =
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?;
shared_contracts::creation_entry_config::validate_unified_creation_spec_for_play(id, &spec)?;
shared_contracts::creation_entry_config::encode_unified_creation_spec_response(&spec).map(Some)
}
fn normalize_category_id(value: &str) -> String {
let normalized = value.trim();
if normalized.is_empty() {

View File

@@ -95,7 +95,7 @@ pub struct VisualNovelWorkProfileRow {
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
// 管理端可见性开关;默认显示,隐藏后不进入广场列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
@@ -171,9 +171,9 @@ pub struct VisualNovelRuntimeEvent {
pub(crate) occurred_at: Timestamp,
}
/// 视觉小说公开广场列表投影。
/// 视觉小说广场列表投影。
///
/// 该 view 只暴露已发布作品卡片需要的公开字段HTTP gallery 订阅
/// 该 view 只暴露已发布作品卡片需要的展示字段HTTP gallery 缓存刷新
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {

View File

@@ -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());
}

View File

@@ -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";