522 lines
19 KiB
Rust
522 lines
19 KiB
Rust
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::<String>)]
|
|
pub(crate) event_title: Option<String>,
|
|
#[default(None::<String>)]
|
|
pub(crate) event_description: Option<String>,
|
|
#[default(None::<String>)]
|
|
pub(crate) event_cover_image_src: Option<String>,
|
|
#[default(DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS)]
|
|
pub(crate) event_prize_pool_mud_points: u64,
|
|
#[default(None::<String>)]
|
|
pub(crate) event_starts_at_text: Option<String>,
|
|
#[default(None::<String>)]
|
|
pub(crate) event_ends_at_text: Option<String>,
|
|
/// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。
|
|
#[default(None::<String>)]
|
|
pub(crate) event_banners_json: Option<String>,
|
|
}
|
|
|
|
#[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::<String>)]
|
|
pub(crate) category_id: Option<String>,
|
|
#[default(None::<String>)]
|
|
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]
|
|
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<CreationEntryConfigSnapshot, String> {
|
|
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<CreationEntryConfigSnapshot, String> {
|
|
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<CreationEntryConfigSnapshot, String> {
|
|
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::<Vec<_>>();
|
|
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<CreationEntryTypeConfig> {
|
|
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<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() {
|
|
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()
|
|
}
|