use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; /// 前台创作入口配置响应。 /// /// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取 /// `event_banners`,由后台表单配置多条公告并支持轮播。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryConfigResponse { pub start_card: CreationEntryStartCardResponse, pub type_modal: CreationEntryTypeModalResponse, pub event_banner: CreationEntryEventBannerResponse, pub event_banners: Vec, pub creation_types: Vec, } /// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryStartCardResponse { pub title: String, pub description: String, pub idle_badge: String, pub busy_badge: String, } /// 创作类型选择弹层的基础文案契约。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryTypeModalResponse { pub title: String, pub description: String, } /// 创作入口单条公告。 /// /// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示; /// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryEventBannerResponse { pub title: String, pub description: String, pub cover_image_src: String, pub prize_pool_mud_points: u64, pub starts_at_text: String, pub ends_at_text: String, #[serde(default = "default_creation_entry_event_banner_render_mode")] pub render_mode: String, #[serde(skip_serializing_if = "Option::is_none")] pub html_code: Option, } /// 默认渲染模式使用受控结构化 UI,用于旧数据兼容。 pub fn default_creation_entry_event_banner_render_mode() -> String { "structured".to_string() } /// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryTypeResponse { pub id: String, pub title: String, pub subtitle: String, pub badge: String, pub image_src: String, pub visible: bool, pub open: bool, pub sort_order: i32, pub category_id: String, 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, } /// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。 #[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, } #[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 { let (workspace_stage, generation_stage, result_stage, fields) = match play_id { "rpg" => ( "agent-workspace", "custom-world-generating", "custom-world-result", vec![unified_creation_field("message", "text", "创作想法", true)], ), "big-fish" => ( "big-fish-agent-workspace", "big-fish-generating", "big-fish-result", vec![unified_creation_field("message", "text", "玩法想法", true)], ), "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("themeText", "text", "主题", 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), ], ), "square-hole" => ( "square-hole-agent-workspace", "square-hole-generating", "square-hole-result", vec![unified_creation_field("message", "text", "玩法想法", true)], ), "bark-battle" => ( "bark-battle-workspace", "bark-battle-generating", "bark-battle-result", vec![ unified_creation_field("title", "text", "作品标题", true), unified_creation_field("themeDescription", "text", "主题/场景描述", true), unified_creation_field("playerImageDescription", "text", "玩家形象描述", true), unified_creation_field("opponentImageDescription", "text", "对手形象描述", true), unified_creation_field("onomatopoeia", "text", "拟声词", false), unified_creation_field("difficultyPreset", "select", "难度", true), ], ), "visual-novel" => ( "visual-novel-agent-workspace", "visual-novel-generating", "visual-novel-result", vec![ unified_creation_field("ideaText", "text", "一句话创作", true), unified_creation_field("visualStyleId", "select", "视觉画风", true), ], ), "baby-object-match" => ( "baby-object-match-workspace", "baby-object-match-generating", "baby-object-match-result", vec![ unified_creation_field("itemAName", "text", "物品 A", true), unified_creation_field("itemBName", "text", "物品 B", true), ], ), "creative-agent" => ( "creative-agent-workspace", "puzzle-generating", "puzzle-result", vec![ unified_creation_field("message", "text", "创作想法", true), unified_creation_field("referenceImage", "image", "参考图", false), ], ), _ => return None, }; Some(UnifiedCreationSpecResponse { play_id: play_id.to_string(), title: default_unified_creation_title(play_id)?.to_string(), workspace_stage: workspace_stage.to_string(), generation_stage: generation_stage.to_string(), result_stage: result_stage.to_string(), fields, }) } pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> { match play_id { "rpg" => Some("文字冒险"), "big-fish" => Some("摸鱼"), "puzzle" => Some("拼图"), "match3d" => Some("抓大鹅"), "jump-hop" => Some("跳一跳"), "wooden-fish" => Some("敲木鱼"), "square-hole" => Some("方洞"), "bark-battle" => Some("汪汪声浪"), "visual-novel" => Some("视觉小说"), "baby-object-match" => Some("宝贝识物"), "creative-agent" => Some("智能体创作"), _ => None, } } 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 { 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 { let spec = serde_json::from_str::(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 { 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_existing_templates() { let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec"); assert_eq!(puzzle.title, "拼图"); 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.title, "抓大鹅"); 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_eq!(jump_hop.title, "跳一跳"); assert_eq!(jump_hop.fields.len(), 1); assert_eq!(jump_hop.fields[0].id, "themeText"); let wooden_fish = build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio")); let visual_novel = build_phase1_unified_creation_spec("visual-novel").expect("visual-novel spec"); assert_eq!(visual_novel.workspace_stage, "visual-novel-agent-workspace"); let bark_battle = build_phase1_unified_creation_spec("bark-battle").expect("bark-battle spec"); assert_eq!(bark_battle.generation_stage, "bark-battle-generating"); let baby_object_match = build_phase1_unified_creation_spec("baby-object-match") .expect("baby-object-match spec"); assert_eq!( baby_object_match .fields .iter() .filter(|field| field.kind == "text") .count(), 2 ); } #[test] fn unified_creation_spec_title_uses_contract_content() { let raw = r#"{ "playId": "puzzle", "title": "想做个什么玩法?", "workspaceStage": "puzzle-agent-workspace", "generationStage": "puzzle-generating", "resultStage": "puzzle-result", "fields": [ { "id": "pictureDescription", "kind": "text", "label": "画面描述", "required": true } ] }"#; let spec = resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec"); assert_eq!(spec.title, "想做个什么玩法?"); } #[test] fn creation_entry_event_banner_defaults_to_structured_render_mode() { let banner = serde_json::from_str::( r#"{ "title": "旧版横幅", "description": "兼容旧字段", "coverImageSrc": "/creation-type-references/puzzle.webp", "prizePoolMudPoints": 1000, "startsAtText": "2026-06-01", "endsAtText": "2026-06-30" }"#, ) .expect("legacy banner json should decode"); assert_eq!(banner.render_mode, "structured"); assert!(banner.html_code.is_none()); } #[test] fn creation_entry_config_serializes_event_banners_contract() { let response = CreationEntryConfigResponse { start_card: CreationEntryStartCardResponse { title: "新建作品".to_string(), description: "选择模板".to_string(), idle_badge: "模板".to_string(), busy_badge: "开启中".to_string(), }, type_modal: CreationEntryTypeModalResponse { title: "选择创作类型".to_string(), description: "先选玩法".to_string(), }, event_banner: CreationEntryEventBannerResponse { title: "第一条".to_string(), description: "兼容单条".to_string(), cover_image_src: "/creation-type-references/puzzle.webp".to_string(), prize_pool_mud_points: 1000, starts_at_text: "2026-06-01".to_string(), ends_at_text: "2026-06-30".to_string(), render_mode: "structured".to_string(), html_code: None, }, event_banners: vec![CreationEntryEventBannerResponse { title: "HTML 条".to_string(), description: "沙箱".to_string(), cover_image_src: "/creation-type-references/match3d.webp".to_string(), prize_pool_mud_points: 800, starts_at_text: "2026-07-01".to_string(), ends_at_text: "2026-07-31".to_string(), render_mode: "html".to_string(), html_code: Some("
ok
".to_string()), }], creation_types: Vec::new(), }; let value = serde_json::to_value(response).expect("response should serialize"); assert_eq!(value["eventBanners"][0]["renderMode"], "html"); assert_eq!( value["eventBanners"][0]["htmlCode"], "
ok
" ); assert!(value.get("event_banner").is_none()); assert!(value.get("eventBanner").is_some()); } }