Files
Genarrative/server-rs/crates/shared-contracts/src/creation_entry_config.rs
T

586 lines
21 KiB
Rust

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<CreationEntryEventBannerResponse>,
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
pub creation_types: Vec<CreationEntryTypeResponse>,
}
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
#[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<String>,
}
/// 默认渲染模式使用受控结构化 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 PublicWorkInteractionConfigResponse {
pub source_type: String,
pub like_enabled: bool,
pub remix_enabled: bool,
pub like_disabled_message: String,
pub remix_disabled_message: 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<UnifiedCreationSpecResponse>,
}
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationSpecResponse {
pub play_id: String,
pub title: String,
#[serde(
default = "default_unified_creation_mud_point_cost",
alias = "mudPointCostText",
deserialize_with = "deserialize_unified_creation_mud_point_cost"
)]
pub mud_point_cost: u32,
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 const DEFAULT_UNIFIED_CREATION_MUD_POINT_COST: u32 = 10;
pub fn default_unified_creation_mud_point_cost() -> u32 {
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST
}
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
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(),
mud_point_cost: default_unified_creation_mud_point_cost(),
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());
}
if spec.mud_point_cost == 0 {
return Err("统一创作契约泥点消耗数量必须大于 0".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)
}
fn deserialize_unified_creation_mud_point_cost<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Number(number) => number
.as_u64()
.and_then(|raw| u32::try_from(raw).ok())
.ok_or_else(|| serde::de::Error::custom("泥点消耗数量必须是非负整数")),
serde_json::Value::String(text) => parse_unified_creation_mud_point_cost_text(&text)
.ok_or_else(|| serde::de::Error::custom("泥点消耗数量必须是数字")),
_ => Err(serde::de::Error::custom("泥点消耗数量必须是数字")),
}
}
fn parse_unified_creation_mud_point_cost_text(value: &str) -> Option<u32> {
let digits: String = value
.chars()
.skip_while(|character| !character.is_ascii_digit())
.take_while(|character| character.is_ascii_digit())
.collect();
digits.parse::<u32>().ok()
}
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_existing_templates() {
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
assert_eq!(puzzle.title, "拼图");
assert_eq!(puzzle.mud_point_cost, 10);
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": "想做个什么玩法?",
"mudPointCost": 12,
"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, "想做个什么玩法?");
assert_eq!(spec.mud_point_cost, 12);
}
#[test]
fn unified_creation_spec_reads_legacy_mud_point_cost_text() {
let raw = r#"{
"playId": "puzzle",
"title": "想做个什么玩法?",
"mudPointCostText": "12泥点数",
"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.mud_point_cost, 12);
}
#[test]
fn unified_creation_spec_uses_default_mud_point_cost_for_legacy_json() {
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.mud_point_cost, 10);
}
#[test]
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
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("<section>ok</section>".to_string()),
}],
public_work_interactions: vec![PublicWorkInteractionConfigResponse {
source_type: "puzzle".to_string(),
like_enabled: true,
remix_enabled: true,
like_disabled_message: "拼图点赞暂不可用。".to_string(),
remix_disabled_message: "拼图作品改造暂不可用。".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"],
"<section>ok</section>"
);
assert!(value.get("event_banner").is_none());
assert!(value.get("eventBanner").is_some());
assert_eq!(value["publicWorkInteractions"][0]["sourceType"], "puzzle");
}
}