feat: 支持创作入口公告配置
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user