Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # .hermes/shared-memory/project-overview.md # docs/【开发运维】本地开发验证与生产运维-2026-05-15.md # scripts/dev.test.ts # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/wooden_fish.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/spacetime-client/src/wooden_fish.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # src/components/custom-world-home/creationWorkShelf.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/rpgEntryWorldPresentation.ts # src/services/miniGameDraftGenerationProgress.test.ts # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -12,11 +12,22 @@ use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
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,
|
||||
@@ -28,35 +39,303 @@ 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()
|
||||
.map(|item| CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
.map(|item| {
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response(
|
||||
item.id.as_str(),
|
||||
item.unified_creation_spec_json.as_deref(),
|
||||
);
|
||||
CreationEntryTypeResponse {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
badge: item.badge,
|
||||
image_src: item.image_src,
|
||||
visible: item.visible,
|
||||
open: item.open,
|
||||
sort_order: item.sort_order,
|
||||
category_id: item.category_id,
|
||||
category_label: item.category_label,
|
||||
category_sort_order: item.category_sort_order,
|
||||
updated_at_micros: item.updated_at_micros,
|
||||
unified_creation_spec,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
|
||||
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> {
|
||||
@@ -70,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(
|
||||
@@ -98,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(
|
||||
@@ -112,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(
|
||||
@@ -274,9 +553,15 @@ fn build_default_creation_entry_type_snapshot(
|
||||
category_label: category_label.to_string(),
|
||||
category_sort_order,
|
||||
updated_at_micros,
|
||||
unified_creation_spec_json: default_unified_creation_spec_json(id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_unified_creation_spec_json(play_id: &str) -> Option<String> {
|
||||
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(play_id)
|
||||
.and_then(|spec| encode_unified_creation_spec_response(&spec).ok())
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
|
||||
RuntimeSettingsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
|
||||
Reference in New Issue
Block a user