use crate::*; #[spacetimedb::table(accessor = creation_entry_config)] pub struct CreationEntryConfig { #[primary_key] pub(crate) config_id: String, pub(crate) start_title: String, pub(crate) start_description: String, pub(crate) start_idle_badge: String, pub(crate) start_busy_badge: String, pub(crate) modal_title: String, pub(crate) modal_description: String, pub(crate) updated_at: Timestamp, #[default(None::)] pub(crate) event_title: Option, #[default(None::)] pub(crate) event_description: Option, #[default(None::)] pub(crate) event_cover_image_src: Option, #[default(DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS)] pub(crate) event_prize_pool_mud_points: u64, #[default(None::)] pub(crate) event_starts_at_text: Option, #[default(None::)] pub(crate) event_ends_at_text: Option, /// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。 #[default(None::)] pub(crate) event_banners_json: Option, } #[spacetimedb::table( accessor = creation_entry_type_config, index(accessor = by_creation_entry_type_sort_order, btree(columns = [sort_order])) )] pub struct CreationEntryTypeConfig { #[primary_key] pub(crate) id: String, pub(crate) title: String, pub(crate) subtitle: String, pub(crate) badge: String, pub(crate) image_src: String, pub(crate) visible: bool, pub(crate) open: bool, pub(crate) sort_order: i32, pub(crate) updated_at: Timestamp, #[default(None::)] pub(crate) category_id: Option, #[default(None::)] pub(crate) category_label: Option, #[default(0)] pub(crate) category_sort_order: i32, #[default(None::)] pub(crate) unified_creation_spec_json: Option, } #[spacetimedb::procedure] pub fn get_creation_entry_config(ctx: &mut ProcedureContext) -> CreationEntryConfigProcedureResult { match ctx.try_with_tx(|tx| get_or_seed_creation_entry_config_snapshot(tx)) { Ok(record) => CreationEntryConfigProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => CreationEntryConfigProcedureResult { ok: false, record: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn upsert_creation_entry_type_config( ctx: &mut ProcedureContext, input: CreationEntryTypeAdminUpsertInput, ) -> CreationEntryConfigProcedureResult { match ctx.try_with_tx(|tx| upsert_creation_entry_type_config_in_tx(tx, input.clone())) { Ok(record) => CreationEntryConfigProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => CreationEntryConfigProcedureResult { ok: false, record: None, error_message: Some(message), }, } } #[spacetimedb::procedure] /// 后台保存底部加号创作入口页多 banner 配置的过程入口。 pub fn upsert_creation_entry_event_banners_config( ctx: &mut ProcedureContext, input: CreationEntryEventBannersAdminUpsertInput, ) -> CreationEntryConfigProcedureResult { match ctx.try_with_tx(|tx| upsert_creation_entry_event_banners_config_in_tx(tx, input.clone())) { Ok(record) => CreationEntryConfigProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => CreationEntryConfigProcedureResult { ok: false, record: None, error_message: Some(message), }, } } fn upsert_creation_entry_type_config_in_tx( ctx: &ReducerContext, input: CreationEntryTypeAdminUpsertInput, ) -> Result { seed_creation_entry_config_if_missing(ctx); let now = ctx.timestamp; let id = input.id.trim().to_string(); if id.is_empty() { return Err("入口 ID 不能为空".to_string()); } 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(), subtitle: input.subtitle.trim().to_string(), badge: input.badge.trim().to_string(), image_src: input.image_src.trim().to_string(), visible: input.visible, open: input.open, sort_order: input.sort_order, updated_at: now, 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); } else { ctx.db.creation_entry_type_config().insert(row); } get_or_seed_creation_entry_config_snapshot(ctx) } /// 在事务内归一化 banner JSON 并更新全局入口配置表头。 fn upsert_creation_entry_event_banners_config_in_tx( ctx: &ReducerContext, input: CreationEntryEventBannersAdminUpsertInput, ) -> Result { seed_creation_entry_config_if_missing(ctx); let now = ctx.timestamp; let config_id = CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(); let Some(header) = ctx.db.creation_entry_config().config_id().find(&config_id) else { return Err("创作入口配置初始化失败".to_string()); }; let event_banners_json = module_runtime::normalize_creation_entry_event_banners_json(&input.event_banners_json)?; ctx.db .creation_entry_config() .config_id() .update(CreationEntryConfig { updated_at: now, event_banners_json: Some(event_banners_json), ..header }); get_or_seed_creation_entry_config_snapshot(ctx) } fn get_or_seed_creation_entry_config_snapshot( ctx: &ReducerContext, ) -> Result { seed_creation_entry_config_if_missing(ctx); let header = ctx .db .creation_entry_config() .config_id() .find(CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string()) .ok_or_else(|| "创作入口配置初始化失败".to_string())?; let mut creation_types = ctx .db .creation_entry_type_config() .iter() .map(|row| CreationEntryTypeSnapshot { id: row.id, title: row.title, subtitle: row.subtitle, badge: row.badge, image_src: row.image_src, visible: row.visible, open: row.open, sort_order: row.sort_order, category_id: normalize_optional_category_id(row.category_id.as_deref()), 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::>(); creation_types.sort_by(|left, right| { left.sort_order .cmp(&right.sort_order) .then_with(|| left.id.cmp(&right.id)) }); Ok(CreationEntryConfigSnapshot { config_id: header.config_id, start_card: CreationEntryStartCardSnapshot { title: header.start_title, description: header.start_description, idle_badge: header.start_idle_badge, busy_badge: header.start_busy_badge, }, type_modal: CreationEntryTypeModalSnapshot { title: header.modal_title, description: header.modal_description, }, event_banner: CreationEntryEventBannerSnapshot { title: normalize_optional_text( header.event_title.as_deref(), DEFAULT_CREATION_ENTRY_EVENT_TITLE, ), description: normalize_optional_text( header.event_description.as_deref(), DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION, ), cover_image_src: normalize_optional_text( header.event_cover_image_src.as_deref(), DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC, ), prize_pool_mud_points: header.event_prize_pool_mud_points, starts_at_text: normalize_optional_text( header.event_starts_at_text.as_deref(), DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT, ), ends_at_text: normalize_optional_text( header.event_ends_at_text.as_deref(), DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT, ), render_mode: "structured".to_string(), html_code: None, }, event_banners_json: header.event_banners_json, creation_types, updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), }) } /// 初始化创作入口全局配置,并为旧库补齐默认多 banner JSON。 fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { let now = ctx.timestamp; if ctx .db .creation_entry_config() .config_id() .find(CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string()) .is_none() { ctx.db.creation_entry_config().insert(CreationEntryConfig { config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(), start_title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(), start_description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(), start_idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(), start_busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(), modal_title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), modal_description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), updated_at: now, event_title: Some(DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string()), event_description: Some(DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string()), event_cover_image_src: Some(DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC.to_string()), event_prize_pool_mud_points: DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()), event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()), event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()), }); } for seed in default_creation_entry_type_configs(now) { if ctx .db .creation_entry_type_config() .id() .find(&seed.id) .is_none() { ctx.db.creation_entry_type_config().insert(seed); } } migrate_rpg_entry_from_old_hidden_default(ctx, now); migrate_visual_novel_entry_from_old_visible_default(ctx, now); migrate_bark_battle_entry_to_open_default(ctx, now); migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now); migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now); } fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { let id = "rpg".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { return; }; // 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。 let still_old_hidden_default = row.title == "文字冒险" && row.subtitle == "经典 RPG 体验" && row.badge == "内测" && row.image_src == "/creation-type-references/rpg.webp" && !row.visible && row.open && row.sort_order == 10; if !still_old_hidden_default { return; } ctx.db .creation_entry_type_config() .id() .update(CreationEntryTypeConfig { badge: "可创建".to_string(), visible: true, open: true, updated_at: now, ..row }); } fn migrate_bark_battle_entry_to_open_default(ctx: &ReducerContext, now: Timestamp) { let id = "bark-battle".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { return; }; // 中文注释:只纠偏系统默认汪汪声浪入口,不覆盖后台手动改过标题、排序或可见性的配置。 let still_system_default = row.title == "汪汪声浪" && row.subtitle == "声控对战挑战" && row.visible && row.sort_order == 85 && (row.image_src == "/creation-type-references/creative-agent.webp" || row.image_src == "/creation-type-references/bark-battle.webp") && ((row.badge == "敬请期待" && !row.open) || (row.badge == "可创建" && row.open)); if !still_system_default { return; } ctx.db .creation_entry_type_config() .id() .update(CreationEntryTypeConfig { badge: "可创建".to_string(), image_src: "/creation-type-references/bark-battle.webp".to_string(), open: true, updated_at: now, ..row }); } fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) { let id = "visual-novel".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { return; }; // 中文注释:只纠偏历史默认种子,不覆盖后台入口开关里后续手动调整过的视觉小说配置。 let still_old_visible_default = row.title == "视觉小说" && row.subtitle == "分支叙事体验" && row.image_src == "/creation-type-references/visual-novel.webp" && row.visible && ((row.badge == "可创建" && row.open) || (row.badge == "敬请期待" && !row.open)) && row.sort_order == 60; if !still_old_visible_default { return; } ctx.db .creation_entry_type_config() .id() .update(CreationEntryTypeConfig { badge: "敬请期待".to_string(), visible: false, open: false, updated_at: now, ..row }); } fn migrate_baby_object_match_entry_from_old_coming_soon_default( ctx: &ReducerContext, now: Timestamp, ) { let id = "baby-object-match".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { return; }; // 中文注释:宝贝识物已接入完整创作发布链路,只纠偏历史默认敬请期待种子。 let still_old_coming_soon_default = row.title == "宝贝识物" && row.subtitle == "亲子识物分类" && row.badge == "敬请期待" && row.image_src == "/child-motion-demo/picture-book-grass-stage.png" && row.visible && !row.open && row.sort_order == 90; if !still_old_coming_soon_default { return; } ctx.db .creation_entry_type_config() .id() .update(CreationEntryTypeConfig { badge: "可创建".to_string(), open: true, updated_at: now, ..row }); } fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext, now: Timestamp) { let id = "wooden-fish".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { return; }; // 中文注释:只替换敲木鱼旧默认入口图,不覆盖后台手动设置过的其它展示信息。 let still_old_puzzle_image_default = row.title == "敲木鱼" && row.subtitle == "点击祈福轻玩法" && row.badge == "可创建" && row.image_src == "/creation-type-references/puzzle.webp" && row.visible && row.open && row.sort_order == 47; if !still_old_puzzle_image_default { return; } ctx.db .creation_entry_type_config() .id() .update(CreationEntryTypeConfig { image_src: "/wooden-fish/default-hit-object.png".to_string(), updated_at: now, ..row }); } fn default_creation_entry_type_configs(now: Timestamp) -> Vec { module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch()) .into_iter() .map(|snapshot| CreationEntryTypeConfig { id: snapshot.id, title: snapshot.title, subtitle: snapshot.subtitle, badge: snapshot.badge, image_src: snapshot.image_src, visible: snapshot.visible, open: snapshot.open, sort_order: snapshot.sort_order, updated_at: now, 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, 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() { DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string() } else { normalized.to_string() } } fn normalize_category_label(value: &str) -> String { let normalized = value.trim(); if normalized.is_empty() { DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string() } else { normalized.to_string() } } fn normalize_optional_category_id(value: Option<&str>) -> String { normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_ID) } fn normalize_optional_category_label(value: Option<&str>) -> String { normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_LABEL) } fn normalize_optional_text(value: Option<&str>, fallback: &str) -> String { value .map(str::trim) .filter(|normalized| !normalized.is_empty()) .unwrap_or(fallback) .to_string() }