feat: 支持创作入口公告配置
This commit is contained in:
@@ -25,8 +25,8 @@ use shared_contracts::admin::{
|
||||
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest, AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
|
||||
@@ -200,6 +200,7 @@ pub async fn admin_list_database_table_rows(
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
|
||||
/// 读取后台创作入口配置,包含模板入口和底部加号入口页公告。
|
||||
pub async fn admin_get_creation_entry_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -212,6 +213,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -221,6 +223,7 @@ pub async fn admin_get_creation_entry_config(
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存单个创作模板入口配置,并返回最新公告与入口快照。
|
||||
pub async fn admin_upsert_creation_entry_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -235,6 +238,38 @@ pub async fn admin_upsert_creation_entry_config(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(map_admin_creation_entry_type_config)
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// 保存底部加号创作入口页的多公告表单序列化配置。
|
||||
pub async fn admin_upsert_creation_entry_event_banners_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertCreationEntryEventBannersRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let normalized_json =
|
||||
module_runtime::normalize_creation_entry_event_banners_json(&payload.event_banners_json)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||
let config = state
|
||||
.upsert_creation_entry_event_banners_config(
|
||||
module_runtime::CreationEntryEventBannersAdminUpsertInput {
|
||||
event_banners_json: normalized_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreationEntryConfigResponse {
|
||||
event_banners: config.event_banners,
|
||||
entries: config
|
||||
.creation_types
|
||||
.into_iter()
|
||||
@@ -321,9 +356,8 @@ fn validate_admin_creation_entry_config(
|
||||
let unified_creation_spec_json = unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| {
|
||||
encode_unified_creation_spec_response(spec).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
|
||||
})
|
||||
encode_unified_creation_spec_response(spec)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(module_runtime::CreationEntryTypeAdminUpsertInput {
|
||||
@@ -1503,11 +1537,7 @@ mod tests {
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
use shared_contracts::admin::{
|
||||
AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload,
|
||||
AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
};
|
||||
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_absolute_url() {
|
||||
|
||||
@@ -518,6 +518,40 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 中文注释:后台路由测试通过真实登录流程取 token,避免绕过鉴权中间件。
|
||||
async fn read_admin_access_token(app: Router) -> String {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "root",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("admin login request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("admin login request should succeed");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("admin login body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("admin login payload should be json");
|
||||
|
||||
payload["token"]
|
||||
.as_str()
|
||||
.expect("admin token should exist")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn password_login_request(
|
||||
app: Router,
|
||||
phone_number: &str,
|
||||
@@ -3980,6 +4014,91 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// 中文注释:验证入口公告表单提交的 HTML 会保存进独立公告配置。
|
||||
#[tokio::test]
|
||||
async fn admin_creation_entry_banners_route_saves_html_form_payload() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/banners")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"eventBannersJson": serde_json::json!([
|
||||
{
|
||||
"title": "后台表单公告",
|
||||
"htmlCode": "<section>入口公告 HTML</section>"
|
||||
}
|
||||
]).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("banners request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("banners request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("banners body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("banners payload should be json");
|
||||
|
||||
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
|
||||
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
payload["eventBanners"][0]["htmlCode"],
|
||||
"<section>入口公告 HTML</section>"
|
||||
);
|
||||
}
|
||||
|
||||
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
|
||||
#[tokio::test]
|
||||
async fn admin_creation_entry_banners_route_rejects_script_html() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let admin_token = read_admin_access_token(app.clone()).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/creation-entry/config/banners")
|
||||
.header("authorization", format!("Bearer {admin_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"eventBannersJson": serde_json::json!([
|
||||
{
|
||||
"title": "危险公告",
|
||||
"htmlCode": "<script>alert(1)</script>"
|
||||
}
|
||||
]).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("banners request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("banners request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -152,7 +152,10 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
|
||||
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
|
||||
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
|
||||
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||
updated_at_micros: 0,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use axum::{Router, middleware, routing::get};
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
|
||||
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
|
||||
admin_login, admin_me, admin_overview, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, require_admin_auth,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -71,6 +75,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/creation-entry/config/banners",
|
||||
post(admin_upsert_creation_entry_event_banners_config).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/works/visibility",
|
||||
get(admin_list_work_visibility)
|
||||
|
||||
@@ -465,6 +465,45 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 SpacetimeDB 保存创作入口页多公告配置,并同步测试缓存。
|
||||
pub async fn upsert_creation_entry_event_banners_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
let test_event_banners_json = input.event_banners_json.clone();
|
||||
match self
|
||||
.spacetime_client
|
||||
.upsert_creation_entry_event_banners_config(input)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let mut config = self.read_test_creation_entry_config();
|
||||
if let Ok(banners) = module_runtime::decode_creation_entry_event_banner_snapshots(
|
||||
test_event_banners_json.as_str(),
|
||||
) {
|
||||
config.event_banners = banners
|
||||
.into_iter()
|
||||
.map(module_runtime::build_creation_entry_event_banner_response)
|
||||
.collect();
|
||||
if let Some(first_banner) = config.event_banners.first().cloned() {
|
||||
config.event_banner = first_banner;
|
||||
}
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config(
|
||||
&self,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
|
||||
@@ -9,9 +9,7 @@ use axum::{
|
||||
};
|
||||
use base64::{
|
||||
Engine as _, alphabet,
|
||||
engine::general_purpose::{
|
||||
GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD,
|
||||
},
|
||||
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
|
||||
@@ -1152,9 +1150,7 @@ fn resolve_wechat_message_push_verify_response(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string())
|
||||
})?;
|
||||
.ok_or_else(|| WechatPayError::InvalidRequest("微信消息推送校验参数不完整".to_string()))?;
|
||||
if !verify_wechat_message_push_signature(token, timestamp, nonce, "", signature) {
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信消息推送校验签名无效".to_string(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::creation_entry_config::UnifiedCreationSpecResponse;
|
||||
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
|
||||
|
||||
// 管理后台协议统一收口在 shared-contracts,避免页面脚本和 Rust handler 各自手拼字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -18,6 +18,8 @@ pub struct AdminLoginRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminCreationEntryConfigResponse {
|
||||
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
|
||||
/// 底部加号创作入口页的后台公告列表。
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
}
|
||||
|
||||
/// 后台单个创作入口开关配置。
|
||||
@@ -59,6 +61,14 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口公告表单序列化结果请求。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertCreationEntryEventBannersRequest {
|
||||
/// 传输字段沿用既有契约,内容由后台标题 / HTML 表单生成。
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 前台创作入口配置响应。
|
||||
///
|
||||
/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取
|
||||
/// `event_banners`,由后台表单配置多条公告并支持轮播。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryConfigResponse {
|
||||
pub start_card: CreationEntryStartCardResponse,
|
||||
pub type_modal: CreationEntryTypeModalResponse,
|
||||
pub event_banner: CreationEntryEventBannerResponse,
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
pub creation_types: Vec<CreationEntryTypeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryStartCardResponse {
|
||||
pub title: String,
|
||||
@@ -19,14 +25,19 @@ pub struct CreationEntryStartCardResponse {
|
||||
pub busy_badge: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作类型选择弹层的基础文案契约。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeModalResponse {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口单条公告。
|
||||
///
|
||||
/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示;
|
||||
/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryEventBannerResponse {
|
||||
pub title: String,
|
||||
@@ -35,9 +46,19 @@ pub struct CreationEntryEventBannerResponse {
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
#[serde(default = "default_creation_entry_event_banner_render_mode")]
|
||||
pub render_mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 默认渲染模式使用受控结构化 UI,用于旧数据兼容。
|
||||
pub fn default_creation_entry_event_banner_render_mode() -> String {
|
||||
"structured".to_string()
|
||||
}
|
||||
|
||||
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeResponse {
|
||||
pub id: String,
|
||||
@@ -56,6 +77,7 @@ pub struct CreationEntryTypeResponse {
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationSpecResponse {
|
||||
@@ -262,8 +284,18 @@ mod tests {
|
||||
);
|
||||
|
||||
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
assert!(jump_hop.fields.iter().any(|field| field.id == "stylePreset"));
|
||||
assert!(jump_hop.fields.iter().any(|field| field.id == "endMoodPrompt"));
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "stylePreset")
|
||||
);
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "endMoodPrompt")
|
||||
);
|
||||
|
||||
let wooden_fish =
|
||||
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
|
||||
@@ -271,4 +303,68 @@ mod tests {
|
||||
assert!(build_phase1_unified_creation_spec("visual-novel").is_none());
|
||||
assert!(build_phase1_unified_creation_spec("bark-battle").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
|
||||
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
|
||||
r#"{
|
||||
"title": "旧版横幅",
|
||||
"description": "兼容旧字段",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 1000,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30"
|
||||
}"#,
|
||||
)
|
||||
.expect("legacy banner json should decode");
|
||||
|
||||
assert_eq!(banner.render_mode, "structured");
|
||||
assert!(banner.html_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_config_serializes_event_banners_contract() {
|
||||
let response = CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: "新建作品".to_string(),
|
||||
description: "选择模板".to_string(),
|
||||
idle_badge: "模板".to_string(),
|
||||
busy_badge: "开启中".to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalResponse {
|
||||
title: "选择创作类型".to_string(),
|
||||
description: "先选玩法".to_string(),
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: "第一条".to_string(),
|
||||
description: "兼容单条".to_string(),
|
||||
cover_image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
prize_pool_mud_points: 1000,
|
||||
starts_at_text: "2026-06-01".to_string(),
|
||||
ends_at_text: "2026-06-30".to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners: vec![CreationEntryEventBannerResponse {
|
||||
title: "HTML 条".to_string(),
|
||||
description: "沙箱".to_string(),
|
||||
cover_image_src: "/creation-type-references/match3d.webp".to_string(),
|
||||
prize_pool_mud_points: 800,
|
||||
starts_at_text: "2026-07-01".to_string(),
|
||||
ends_at_text: "2026-07-31".to_string(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some("<section>ok</section>".to_string()),
|
||||
}],
|
||||
creation_types: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(response).expect("response should serialize");
|
||||
|
||||
assert_eq!(value["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
value["eventBanners"][0]["htmlCode"],
|
||||
"<section>ok</section>"
|
||||
);
|
||||
assert!(value.get("event_banner").is_none());
|
||||
assert!(value.get("eventBanner").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,9 +1154,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。
|
||||
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
|
||||
JumpHopActionRequest {
|
||||
action_type,
|
||||
profile_id: None,
|
||||
work_title: None,
|
||||
work_description: None,
|
||||
theme_tags: None,
|
||||
@@ -1165,6 +1167,10 @@ mod tests {
|
||||
character_prompt: None,
|
||||
tile_prompt: None,
|
||||
end_mood_prompt: None,
|
||||
character_asset: None,
|
||||
tile_atlas_asset: None,
|
||||
tile_assets: None,
|
||||
cover_composite: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ mod tests {
|
||||
let row = BarkBattleGalleryViewRow {
|
||||
work_id: "BB-33333333".to_string(),
|
||||
owner_user_id: "user-3".to_string(),
|
||||
author_display_name: "声浪玩家".to_string(),
|
||||
source_draft_id: Some("bark-battle-draft-3".to_string()),
|
||||
config_version: 1,
|
||||
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
||||
|
||||
@@ -19,6 +19,17 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
|
||||
}
|
||||
}
|
||||
|
||||
/// 将业务层 banner JSON 保存输入转换为 SpacetimeDB 生成绑定类型。
|
||||
impl From<module_runtime::CreationEntryEventBannersAdminUpsertInput>
|
||||
for CreationEntryEventBannersAdminUpsertInput
|
||||
{
|
||||
fn from(input: module_runtime::CreationEntryEventBannersAdminUpsertInput) -> Self {
|
||||
Self {
|
||||
event_banners_json: input.event_banners_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::AdminWorkVisibilityListInput> for AdminWorkVisibilityListInput {
|
||||
fn from(input: module_runtime::AdminWorkVisibilityListInput) -> Self {
|
||||
Self {
|
||||
@@ -233,6 +244,7 @@ fn map_admin_work_visibility_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地订阅表行组装创作入口配置响应,兼容旧单条 banner 字段。
|
||||
pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
header: CreationEntryConfig,
|
||||
mut creation_types: Vec<CreationEntryTypeConfig>,
|
||||
@@ -278,7 +290,10 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
header.event_ends_at_text,
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
|
||||
),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners_json: header.event_banners_json,
|
||||
creation_types: creation_types
|
||||
.into_iter()
|
||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
||||
@@ -308,6 +323,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
||||
)
|
||||
}
|
||||
|
||||
/// 将 SpacetimeDB procedure 快照映射为 module-runtime 领域快照。
|
||||
fn map_creation_entry_config_snapshot(
|
||||
snapshot: CreationEntryConfigSnapshot,
|
||||
) -> module_runtime::CreationEntryConfigSnapshot {
|
||||
@@ -330,7 +346,10 @@ fn map_creation_entry_config_snapshot(
|
||||
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,
|
||||
render_mode: snapshot.event_banner.render_mode,
|
||||
html_code: snapshot.event_banner.html_code,
|
||||
},
|
||||
event_banners_json: snapshot.event_banners_json,
|
||||
creation_types: snapshot
|
||||
.creation_types
|
||||
.into_iter()
|
||||
|
||||
@@ -242,6 +242,7 @@ pub mod creation_entry_config_snapshot_type;
|
||||
pub mod creation_entry_config_table;
|
||||
pub mod creation_entry_config_type;
|
||||
pub mod creation_entry_event_banner_snapshot_type;
|
||||
pub mod creation_entry_event_banners_admin_upsert_input_type;
|
||||
pub mod creation_entry_start_card_snapshot_type;
|
||||
pub mod creation_entry_type_admin_upsert_input_type;
|
||||
pub mod creation_entry_type_config_table;
|
||||
@@ -949,6 +950,7 @@ pub mod update_visual_novel_work_procedure;
|
||||
pub mod update_wooden_fish_work_procedure;
|
||||
pub mod upsert_chapter_progression_and_return_procedure;
|
||||
pub mod upsert_chapter_progression_reducer;
|
||||
pub mod upsert_creation_entry_event_banners_config_procedure;
|
||||
pub mod upsert_creation_entry_type_config_procedure;
|
||||
pub mod upsert_custom_world_agent_operation_progress_procedure;
|
||||
pub mod upsert_custom_world_profile_and_return_procedure;
|
||||
@@ -1281,6 +1283,7 @@ pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
|
||||
pub use creation_entry_config_table::*;
|
||||
pub use creation_entry_config_type::CreationEntryConfig;
|
||||
pub use creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot;
|
||||
pub use creation_entry_event_banners_admin_upsert_input_type::CreationEntryEventBannersAdminUpsertInput;
|
||||
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
|
||||
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
|
||||
pub use creation_entry_type_config_table::*;
|
||||
@@ -1988,6 +1991,7 @@ pub use update_visual_novel_work_procedure::update_visual_novel_work;
|
||||
pub use update_wooden_fish_work_procedure::update_wooden_fish_work;
|
||||
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
|
||||
pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
|
||||
pub use upsert_creation_entry_event_banners_config_procedure::upsert_creation_entry_event_banners_config;
|
||||
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;
|
||||
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
|
||||
pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct CreationEntryConfigSnapshot {
|
||||
pub start_card: CreationEntryStartCardSnapshot,
|
||||
pub type_modal: CreationEntryTypeModalSnapshot,
|
||||
pub event_banner: CreationEntryEventBannerSnapshot,
|
||||
pub event_banners_json: Option<String>,
|
||||
pub creation_types: Vec<CreationEntryTypeSnapshot>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct CreationEntryConfig {
|
||||
pub event_prize_pool_mud_points: u64,
|
||||
pub event_starts_at_text: Option<String>,
|
||||
pub event_ends_at_text: Option<String>,
|
||||
pub event_banners_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryConfig {
|
||||
@@ -45,6 +46,7 @@ pub struct CreationEntryConfigCols {
|
||||
pub event_prize_pool_mud_points: __sdk::__query_builder::Col<CreationEntryConfig, u64>,
|
||||
pub event_starts_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_ends_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
pub event_banners_json: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
|
||||
@@ -74,6 +76,7 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig {
|
||||
"event_starts_at_text",
|
||||
),
|
||||
event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"),
|
||||
event_banners_json: __sdk::__query_builder::Col::new(table_name, "event_banners_json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,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>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryEventBannerSnapshot {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct CreationEntryEventBannersAdminUpsertInput {
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreationEntryEventBannersAdminUpsertInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult;
|
||||
use super::creation_entry_event_banners_admin_upsert_input_type::CreationEntryEventBannersAdminUpsertInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct UpsertCreationEntryEventBannersConfigArgs {
|
||||
pub input: CreationEntryEventBannersAdminUpsertInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for UpsertCreationEntryEventBannersConfigArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `upsert_creation_entry_event_banners_config`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait upsert_creation_entry_event_banners_config {
|
||||
fn upsert_creation_entry_event_banners_config(
|
||||
&self,
|
||||
input: CreationEntryEventBannersAdminUpsertInput,
|
||||
) {
|
||||
self.upsert_creation_entry_event_banners_config_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn upsert_creation_entry_event_banners_config_then(
|
||||
&self,
|
||||
input: CreationEntryEventBannersAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl upsert_creation_entry_event_banners_config for super::RemoteProcedures {
|
||||
fn upsert_creation_entry_event_banners_config_then(
|
||||
&self,
|
||||
input: CreationEntryEventBannersAdminUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CreationEntryConfigProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>(
|
||||
"upsert_creation_entry_event_banners_config",
|
||||
UpsertCreationEntryEventBannersConfigArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,34 @@ impl SpacetimeClient {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 调用 SpacetimeDB procedure 保存创作入口页多 banner 配置并刷新缓存。
|
||||
pub async fn upsert_creation_entry_event_banners_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryEventBannersAdminUpsertInput,
|
||||
) -> Result<CreationEntryConfigRecord, SpacetimeClientError> {
|
||||
let procedure_input: CreationEntryEventBannersAdminUpsertInput = input.into();
|
||||
let config = self
|
||||
.call_after_connect(
|
||||
"upsert_creation_entry_event_banners_config",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.upsert_creation_entry_event_banners_config_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_creation_entry_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.cache_creation_entry_config(config.clone()).await;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn admin_list_work_visibility(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
|
||||
@@ -1180,6 +1180,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("event_ends_at_text".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
object
|
||||
.entry("event_banners_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
if table_name == "creation_entry_type_config" {
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct CreationEntryConfig {
|
||||
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(
|
||||
@@ -85,6 +88,27 @@ pub fn upsert_creation_entry_type_config(
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -122,6 +146,31 @@ fn upsert_creation_entry_type_config_in_tx(
|
||||
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> {
|
||||
@@ -192,12 +241,16 @@ fn get_or_seed_creation_entry_config_snapshot(
|
||||
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
|
||||
@@ -222,6 +275,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user