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(