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

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