feat: 支持创作入口公告配置

This commit is contained in:
2026-06-03 03:31:45 +08:00
parent 1cb11bc1dd
commit 70ff18ad90
52 changed files with 3045 additions and 504 deletions

View File

@@ -15,9 +15,19 @@ use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
};
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
pub fn build_creation_entry_config_response(
snapshot: CreationEntryConfigSnapshot,
) -> CreationEntryConfigResponse {
let event_banners = resolve_creation_entry_event_banner_responses(
snapshot.event_banners_json.as_deref(),
&snapshot.event_banner,
);
let event_banner = event_banners
.first()
.cloned()
.unwrap_or_else(|| build_creation_entry_event_banner_response(snapshot.event_banner));
CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
title: snapshot.start_card.title,
@@ -29,14 +39,8 @@ pub fn build_creation_entry_config_response(
title: snapshot.type_modal.title,
description: snapshot.type_modal.description,
},
event_banner: CreationEntryEventBannerResponse {
title: snapshot.event_banner.title,
description: snapshot.event_banner.description,
cover_image_src: snapshot.event_banner.cover_image_src,
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
starts_at_text: snapshot.event_banner.starts_at_text,
ends_at_text: snapshot.event_banner.ends_at_text,
},
event_banner,
event_banners,
creation_types: snapshot
.creation_types
.into_iter()
@@ -65,6 +69,273 @@ pub fn build_creation_entry_config_response(
}
}
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
pub fn default_creation_entry_event_banner_snapshots() -> Vec<CreationEntryEventBannerSnapshot> {
vec![CreationEntryEventBannerSnapshot {
title: "创作公告".to_string(),
description: String::new(),
cover_image_src: String::new(),
prize_pool_mud_points: 0,
starts_at_text: String::new(),
ends_at_text: String::new(),
render_mode: "html".to_string(),
html_code: Some(
r#"<section style="box-sizing:border-box;width:100%;min-height:180px;padding:28px 30px;border-radius:24px;background:#fff7ed;color:#6f2f21;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"><h1 style="margin:0 0 10px;font-size:28px;">创作公告</h1><p style="margin:0;font-size:16px;line-height:1.7;">这里可以在后台替换成你的公告 HTML。</p></section>"#
.to_string(),
),
}]
}
/// 生成默认公告 JSON供 SpacetimeDB 表字段持久化。
pub fn default_creation_entry_event_banners_json() -> String {
encode_creation_entry_event_banner_snapshots(&default_creation_entry_event_banner_snapshots())
.unwrap_or_else(|_| "[]".to_string())
}
/// 校验并归一后台公告表单序列化后的持久化 JSON。
pub fn normalize_creation_entry_event_banners_json(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_creation_entry_event_banners_json());
}
let banners = decode_creation_entry_event_banner_snapshots(trimmed)?;
encode_creation_entry_event_banner_snapshots(&banners)
}
/// 解析后台公告持久化 JSON输出已归一化的领域快照。
pub fn decode_creation_entry_event_banner_snapshots(
input: &str,
) -> Result<Vec<CreationEntryEventBannerSnapshot>, String> {
let raw_value =
serde_json::from_str::<Value>(input).map_err(|error| format!("公告 JSON 非法:{error}"))?;
let banners = raw_value
.as_array()
.ok_or_else(|| "公告 JSON 必须是数组".to_string())?;
if banners.is_empty() {
return Err("公告至少需要配置一条".to_string());
}
if banners.len() > CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT {
return Err(format!(
"公告最多配置 {}",
CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT
));
}
banners
.iter()
.enumerate()
.map(|(index, banner)| normalize_creation_entry_announcement_banner_value(index, banner))
.collect()
}
/// 归一后台公告配置:新格式支持 HTML 字符串 / `{title, htmlCode}`,旧结构化 banner 保持兼容。
fn normalize_creation_entry_announcement_banner_value(
index: usize,
value: &Value,
) -> Result<CreationEntryEventBannerSnapshot, String> {
if let Some(html_code) = value.as_str() {
return build_creation_entry_html_announcement_snapshot(index, None, html_code.to_string());
}
let Some(object) = value.as_object() else {
return Err(format!("{} 条公告必须是 HTML 字符串或对象", index + 1));
};
let explicit_render_mode = value
.get("renderMode")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !explicit_render_mode.is_empty() && !explicit_render_mode.eq_ignore_ascii_case("html") {
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
object.clone(),
))
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
return normalize_creation_entry_event_banner_response(index, banner);
}
if let Some(html_code) = read_announcement_html_code(value) {
return build_creation_entry_html_announcement_snapshot(
index,
read_announcement_title(value),
html_code,
);
}
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
object.clone(),
))
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
normalize_creation_entry_event_banner_response(index, banner)
}
/// 将后台公告 HTML 代码包装成前台沙箱 iframe 可渲染的 banner 快照。
fn build_creation_entry_html_announcement_snapshot(
index: usize,
title: Option<String>,
html_code: String,
) -> Result<CreationEntryEventBannerSnapshot, String> {
Ok(CreationEntryEventBannerSnapshot {
title: title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| format!("公告 {}", index + 1)),
description: String::new(),
cover_image_src: String::new(),
prize_pool_mud_points: 0,
starts_at_text: String::new(),
ends_at_text: String::new(),
render_mode: "html".to_string(),
html_code: normalize_banner_html_code(index, "html", Some(html_code))?,
})
}
/// 读取公告对象标题,兼容 title/name 两种后台填写习惯。
fn read_announcement_title(value: &Value) -> Option<String> {
read_string_field(value, &["title", "name"])
}
/// 读取公告 HTML 代码,兼容 htmlCode/html/code 三种后台填写习惯。
fn read_announcement_html_code(value: &Value) -> Option<String> {
read_string_field(value, &["htmlCode", "html", "code"])
}
/// 从 JSON 对象读取第一个非空字符串字段。
fn read_string_field(value: &Value, field_names: &[&str]) -> Option<String> {
field_names.iter().find_map(|field_name| {
value
.get(*field_name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
}
/// 把公告领域快照编码为稳定 JSON。
pub fn encode_creation_entry_event_banner_snapshots(
banners: &[CreationEntryEventBannerSnapshot],
) -> Result<String, String> {
if banners.is_empty() {
return Err("公告至少需要配置一条".to_string());
}
let responses = banners
.iter()
.cloned()
.map(build_creation_entry_event_banner_response)
.collect::<Vec<_>>();
serde_json::to_string_pretty(&responses)
.map_err(|error| format!("公告 JSON 序列化失败:{error}"))
}
/// 根据持久化 JSON 或旧单条字段得到前台可渲染公告列表。
pub fn resolve_creation_entry_event_banner_responses(
event_banners_json: Option<&str>,
fallback_banner: &CreationEntryEventBannerSnapshot,
) -> Vec<CreationEntryEventBannerResponse> {
event_banners_json
.and_then(|raw| decode_creation_entry_event_banner_snapshots(raw).ok())
.filter(|banners| !banners.is_empty())
.unwrap_or_else(|| vec![fallback_banner.clone()])
.into_iter()
.map(build_creation_entry_event_banner_response)
.collect()
}
/// 把领域公告快照转换为 HTTP 响应字段。
pub fn build_creation_entry_event_banner_response(
banner: CreationEntryEventBannerSnapshot,
) -> CreationEntryEventBannerResponse {
CreationEntryEventBannerResponse {
title: banner.title,
description: banner.description,
cover_image_src: banner.cover_image_src,
prize_pool_mud_points: banner.prize_pool_mud_points,
starts_at_text: banner.starts_at_text,
ends_at_text: banner.ends_at_text,
render_mode: normalize_banner_render_mode(&banner.render_mode),
html_code: banner.html_code,
}
}
/// 校验旧结构化 banner 响应并转换为领域公告快照。
fn normalize_creation_entry_event_banner_response(
index: usize,
banner: CreationEntryEventBannerResponse,
) -> Result<CreationEntryEventBannerSnapshot, String> {
let render_mode = normalize_banner_render_mode(&banner.render_mode);
let html_code = normalize_banner_html_code(index, render_mode.as_str(), banner.html_code)?;
let default_banner = default_creation_entry_event_banner_snapshots()
.into_iter()
.next()
.expect("default banner should exist");
Ok(CreationEntryEventBannerSnapshot {
title: normalize_banner_text(banner.title, default_banner.title),
description: normalize_banner_text(banner.description, default_banner.description),
cover_image_src: normalize_banner_text(
banner.cover_image_src,
default_banner.cover_image_src,
),
prize_pool_mud_points: banner.prize_pool_mud_points,
starts_at_text: normalize_banner_text(banner.starts_at_text, default_banner.starts_at_text),
ends_at_text: normalize_banner_text(banner.ends_at_text, default_banner.ends_at_text),
render_mode,
html_code,
})
}
/// 归一化公告渲染模式,未知值统一回到结构化兼容 UI。
fn normalize_banner_render_mode(value: &str) -> String {
if value.trim().eq_ignore_ascii_case("html") {
"html".to_string()
} else {
"structured".to_string()
}
}
/// 清理旧结构化 banner 文案字段,空值沿用平台默认文案。
fn normalize_banner_text(value: String, fallback: String) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback
} else {
trimmed.to_string()
}
}
/// 校验 HTML 公告片段,只允许交给前端沙箱 iframe 展示。
fn normalize_banner_html_code(
index: usize,
render_mode: &str,
value: Option<String>,
) -> Result<Option<String>, String> {
if render_mode != "html" {
return Ok(None);
}
let html_code = value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("{} 条 HTML 公告缺少 htmlCode", index + 1))?;
if html_code.len() > CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES {
return Err(format!(
"{} 条 HTML 公告超过 {} 字节",
index + 1,
CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES
));
}
let lower_html_code = html_code.to_ascii_lowercase();
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
return Err(format!(
"{} 条 HTML 公告含有不允许的脚本代码",
index + 1
));
}
Ok(Some(html_code))
}
pub fn default_creation_entry_type_snapshots(
updated_at_micros: i64,
) -> Vec<CreationEntryTypeSnapshot> {
@@ -78,9 +349,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
10,
"recent",
"最近创作",
10,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -106,9 +377,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
30,
"recent",
"最近创作",
10,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -120,9 +391,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
40,
"recent",
"最近创作",
10,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(

View File

@@ -51,8 +51,9 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab";
pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启";
pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型";
pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。";
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent";
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作";
/// 创作模板分类缺省回退到推荐,不再把真实作品维度的“最近创作”种成模板页签。
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recommended";
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "热门推荐";
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
@@ -60,6 +61,10 @@ pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
/// 后台创作入口公告最多允许配置的轮播条数。
pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
/// 单条 HTML 公告的代码大小上限,避免后台误贴超大片段拖慢入口页。
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -77,6 +82,7 @@ pub struct CreationEntryTypeModalSnapshot {
pub description: String,
}
/// 创作入口公告快照,支持 HTML 公告渲染和旧结构化 banner 兼容。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryEventBannerSnapshot {
@@ -86,6 +92,8 @@ pub struct CreationEntryEventBannerSnapshot {
pub prize_pool_mud_points: u64,
pub starts_at_text: String,
pub ends_at_text: String,
pub render_mode: String,
pub html_code: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -106,6 +114,7 @@ pub struct CreationEntryTypeSnapshot {
pub unified_creation_spec_json: Option<String>,
}
/// 创作入口全局配置快照,供前台入口页和后台配置页共同读取。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryConfigSnapshot {
@@ -113,6 +122,8 @@ pub struct CreationEntryConfigSnapshot {
pub start_card: CreationEntryStartCardSnapshot,
pub type_modal: CreationEntryTypeModalSnapshot,
pub event_banner: CreationEntryEventBannerSnapshot,
/// 底部加号创作入口页的多公告 JSON 配置;旧库为空时由应用层兜底。
pub event_banners_json: Option<String>,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
}
@@ -134,6 +145,14 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub unified_creation_spec_json: Option<String>,
}
/// 后台保存创作入口多公告表单序列化结果的领域输入。
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryEventBannersAdminUpsertInput {
/// 持久化字段沿用 JSON 字符串,内容由后台表单生成。
pub event_banners_json: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryConfigProcedureResult {

View File

@@ -253,11 +253,135 @@ mod tests {
assert!(rpg.open);
assert_eq!(rpg.badge, "可创建");
assert_eq!(rpg.sort_order, 10);
assert_eq!(rpg.category_id, "recent");
assert_eq!(rpg.category_label, "最近创作");
assert_eq!(rpg.category_id, "recommended");
assert_eq!(rpg.category_label, "热门推荐");
assert_eq!(rpg.category_sort_order, 20);
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
}
#[test]
fn default_creation_entry_types_do_not_seed_recent_as_template_category() {
let configs = default_creation_entry_type_snapshots(1);
assert!(configs.iter().all(|item| item.category_id != "recent"));
assert!(configs.iter().all(|item| item.category_label != "最近创作"));
}
#[test]
fn creation_entry_event_banners_json_normalizes_multiple_banners() {
let normalized = normalize_creation_entry_event_banners_json(
r#"[
{
"title": " 周末拼图赛 ",
"description": " 拼一个新主题 ",
"coverImageSrc": "/creation-type-references/puzzle.webp",
"prizePoolMudPoints": 1200,
"startsAtText": "2026-06-01",
"endsAtText": "2026-06-30",
"renderMode": "structured",
"htmlCode": "<div>ignored</div>"
},
{
"title": "HTML 横幅",
"description": "沙箱片段",
"coverImageSrc": "/creation-type-references/match3d.webp",
"prizePoolMudPoints": 900,
"startsAtText": "2026-07-01",
"endsAtText": "2026-07-31",
"renderMode": "html",
"htmlCode": " <section>安全片段</section> "
}
]"#,
)
.expect("valid banner json should normalize");
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
.expect("normalized banner json should decode");
assert_eq!(banners.len(), 2);
assert_eq!(banners[0].title, "周末拼图赛");
assert_eq!(banners[0].description, "拼一个新主题");
assert_eq!(banners[0].render_mode, "structured");
assert!(banners[0].html_code.is_none());
assert_eq!(banners[1].render_mode, "html");
assert_eq!(
banners[1].html_code.as_deref(),
Some("<section>安全片段</section>")
);
}
#[test]
fn creation_entry_event_banners_json_empty_input_returns_defaults() {
let normalized = normalize_creation_entry_event_banners_json(" ")
.expect("blank banner json should use defaults");
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
.expect("default banner json should decode");
assert_eq!(banners, default_creation_entry_event_banner_snapshots());
}
#[test]
fn creation_entry_event_banners_json_accepts_announcement_html_code() {
let normalized = normalize_creation_entry_event_banners_json(
r#"[
"<section>纯 HTML 公告</section>",
{"title": "后台公告", "htmlCode": "<article>自定义公告</article>"}
]"#,
)
.expect("announcement html json should normalize");
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
.expect("normalized announcement json should decode");
assert_eq!(banners.len(), 2);
assert_eq!(banners[0].title, "公告 1");
assert_eq!(banners[0].render_mode, "html");
assert_eq!(
banners[0].html_code.as_deref(),
Some("<section>纯 HTML 公告</section>")
);
assert_eq!(banners[1].title, "后台公告");
assert_eq!(
banners[1].html_code.as_deref(),
Some("<article>自定义公告</article>")
);
}
#[test]
fn creation_entry_event_banners_json_rejects_script_like_html() {
let script_error = normalize_creation_entry_event_banners_json(
r#"[
{
"title": "脚本横幅",
"description": "不允许脚本",
"coverImageSrc": "/creation-type-references/puzzle.webp",
"prizePoolMudPoints": 100,
"startsAtText": "2026-06-01",
"endsAtText": "2026-06-30",
"renderMode": "html",
"htmlCode": "<script>alert(1)</script>"
}
]"#,
)
.expect_err("script tag should be rejected");
let javascript_url_error = normalize_creation_entry_event_banners_json(
r#"[
{
"title": "链接横幅",
"description": "不允许 javascript URL",
"coverImageSrc": "/creation-type-references/puzzle.webp",
"prizePoolMudPoints": 100,
"startsAtText": "2026-06-01",
"endsAtText": "2026-06-30",
"renderMode": "html",
"htmlCode": "<a href=\"javascript:alert(1)\">bad</a>"
}
]"#,
)
.expect_err("javascript url should be rejected");
assert!(script_error.contains("脚本代码"));
assert!(javascript_url_error.contains("脚本代码"));
}
#[test]
fn default_creation_entry_types_include_bark_battle() {
let configs = default_creation_entry_type_snapshots(1);