Files
Genarrative/server-rs/crates/module-runtime/src/application.rs
T
kdletters 93ed7f2c57
Project CI / Repository checks (push) Successful in 1m0s
Project CI / Backend tests (push) Successful in 3m30s
Project CI / Frontend tests (push) Successful in 2m57s
Project CI / Native shell tests (push) Successful in 12m11s
将每日免费泥点纳入后台配置
在账号配置页一次维护初始泥点和每日免费额度
将每日额度贯穿钱包配置、日切重置与充值中心投影
补齐迁移兼容、生成绑定、测试和文档
2026-07-31 13:04:27 +08:00

2700 lines
94 KiB
Rust

//! 运行时应用编排。
//!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
#[cfg(any())]
use serde_json::Value;
#[cfg(any())]
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use std::collections::{BTreeMap, BTreeSet};
use crate::domain::*;
use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
#[cfg(any())]
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, PublicWorkInteractionConfigResponse,
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
};
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
#[cfg(any())]
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));
let public_work_interactions = resolve_public_work_interaction_config_responses(
snapshot.public_work_interactions_json.as_deref(),
);
CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
title: snapshot.start_card.title,
description: snapshot.start_card.description,
idle_badge: snapshot.start_card.idle_badge,
busy_badge: snapshot.start_card.busy_badge,
},
type_modal: CreationEntryTypeModalResponse {
title: snapshot.type_modal.title,
description: snapshot.type_modal.description,
},
event_banner,
event_banners,
public_work_interactions,
creation_types: snapshot
.creation_types
.into_iter()
.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(),
}
}
#[cfg(any())]
pub fn creation_entry_feature_gate_key(creation_type_id: &str) -> String {
format!("creation-entry:{}", creation_type_id.trim())
}
pub const IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY: &str = "image-editor:agent-sidebar";
#[cfg(any())]
pub fn apply_feature_gates_to_creation_entry_config(
mut config: CreationEntryConfigResponse,
gates: &[FeatureGateConfigSnapshot],
user: &FeatureGateUserContext,
) -> CreationEntryConfigResponse {
let gates_by_key = gates
.iter()
.map(|gate| (gate.gate_key.as_str(), gate))
.collect::<BTreeMap<_, _>>();
for entry in &mut config.creation_types {
let gate_key = creation_entry_feature_gate_key(&entry.id);
if !is_feature_gate_allowed(gates_by_key.get(gate_key.as_str()).copied(), user) {
entry.visible = false;
entry.open = false;
}
}
config
}
pub fn is_feature_gate_allowed(
gate: Option<&FeatureGateConfigSnapshot>,
user: &FeatureGateUserContext,
) -> bool {
let Some(gate) = gate else {
return true;
};
if !gate.enabled {
return true;
}
let Some(user_id) = user
.user_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
return false;
};
if gate.deny_user_ids.iter().any(|id| id == user_id) {
return false;
}
if gate.allow_user_ids.iter().any(|id| id == user_id) {
return true;
}
let user_tags = user
.user_tags
.iter()
.map(|tag| tag.as_str())
.collect::<BTreeSet<_>>();
if gate
.allow_user_tags
.iter()
.any(|tag| user_tags.contains(tag.as_str()))
{
return true;
}
if gate.rollout_percent == 0 {
return false;
}
if gate.rollout_percent >= 100 {
return true;
}
stable_feature_gate_bucket(user_id, &gate.gate_key) < gate.rollout_percent
}
pub fn stable_feature_gate_bucket(user_id: &str, gate_key: &str) -> u32 {
let mut hash = 0xcbf29ce484222325_u64;
for byte in user_id
.trim()
.bytes()
.chain([0xff])
.chain(gate_key.trim().bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
(hash % 100) as u32
}
pub fn normalize_feature_gate_admin_upsert_input(
input: FeatureGateConfigAdminUpsertInput,
) -> Result<FeatureGateConfigAdminUpsertInput, String> {
let gate_key = normalize_feature_gate_key(input.gate_key)?;
let description = normalize_feature_gate_description(input.description)?;
Ok(FeatureGateConfigAdminUpsertInput {
gate_key,
enabled: input.enabled,
rollout_percent: input.rollout_percent.min(100),
allow_user_ids: normalize_feature_gate_list(input.allow_user_ids, "用户 ID 白名单")?,
allow_user_tags: normalize_feature_gate_list(input.allow_user_tags, "用户标签白名单")?,
deny_user_ids: normalize_feature_gate_list(input.deny_user_ids, "用户 ID 黑名单")?,
description,
})
}
fn normalize_feature_gate_key(value: String) -> Result<String, String> {
let gate_key = value.trim().to_string();
if gate_key.is_empty() {
return Err("灰度 key 不能为空".to_string());
}
if gate_key.len() > FEATURE_GATE_KEY_MAX_CHARS {
return Err(format!(
"灰度 key 最多允许 {} 个字符",
FEATURE_GATE_KEY_MAX_CHARS
));
}
Ok(gate_key)
}
fn normalize_feature_gate_description(value: String) -> Result<String, String> {
let description = value.trim().to_string();
if description.len() > FEATURE_GATE_DESCRIPTION_MAX_CHARS {
return Err(format!(
"灰度说明最多允许 {} 个字符",
FEATURE_GATE_DESCRIPTION_MAX_CHARS
));
}
Ok(description)
}
fn normalize_feature_gate_list(values: Vec<String>, label: &str) -> Result<Vec<String>, String> {
let mut seen = BTreeSet::<String>::new();
for value in values {
let item = value.trim().to_string();
if item.is_empty() {
continue;
}
if item.len() > FEATURE_GATE_LIST_ITEM_MAX_CHARS {
return Err(format!(
"{label}单项最多允许 {} 个字符",
FEATURE_GATE_LIST_ITEM_MAX_CHARS
));
}
seen.insert(item);
}
if seen.len() > FEATURE_GATE_LIST_MAX_COUNT {
return Err(format!(
"{label}最多允许 {} 项",
FEATURE_GATE_LIST_MAX_COUNT
));
}
Ok(seen.into_iter().collect())
}
/// 返回公开作品点赞 / 改造默认矩阵,保持历史前端硬编码能力不变。
#[cfg(any())]
pub fn default_public_work_interaction_config_snapshots() -> Vec<PublicWorkInteractionConfigSnapshot>
{
vec![
public_work_interaction_config(
"custom-world",
true,
true,
"RPG 作品暂不支持点赞。",
"RPG 作品暂不支持改造。",
),
public_work_interaction_config(
"big-fish",
true,
true,
"摸鱼点赞暂不可用。",
"摸鱼作品改造暂不可用。",
),
public_work_interaction_config(
"puzzle",
true,
true,
"拼图点赞暂不可用。",
"拼图作品改造暂不可用。",
),
public_work_interaction_config(
"puzzle-clear",
false,
false,
"拼消消点赞将在后续版本开放。",
"拼消消作品改造将在后续版本开放。",
),
public_work_interaction_config(
"wooden-fish",
false,
false,
"作品类型 wooden-fish 暂不支持点赞。",
"敲木鱼作品改造将在后续版本开放。",
),
public_work_interaction_config(
"square-hole",
false,
false,
"方洞挑战点赞将在后续版本开放。",
"方洞挑战作品改造将在后续版本开放。",
),
public_work_interaction_config(
"visual-novel",
false,
false,
"视觉小说点赞将在后续版本开放。",
"视觉小说作品改造将在后续版本开放。",
),
public_work_interaction_config(
"bark-battle",
false,
false,
"汪汪声浪点赞将在后续版本开放。",
"汪汪声浪作品改造将在后续版本开放。",
),
public_work_interaction_config(
"edutainment",
false,
false,
"宝贝识物点赞将在后续版本开放。",
"宝贝识物作品改造将在创作链路接入后开放。",
),
]
}
#[cfg(any())]
fn public_work_interaction_config(
source_type: &str,
like_enabled: bool,
remix_enabled: bool,
like_disabled_message: &str,
remix_disabled_message: &str,
) -> PublicWorkInteractionConfigSnapshot {
PublicWorkInteractionConfigSnapshot {
source_type: source_type.to_string(),
like_enabled,
remix_enabled,
like_disabled_message: like_disabled_message.to_string(),
remix_disabled_message: remix_disabled_message.to_string(),
}
}
/// 生成默认公开作品互动配置 JSON,供 SpacetimeDB 表字段持久化。
#[cfg(any())]
pub fn default_public_work_interaction_config_json() -> String {
encode_public_work_interaction_config_snapshots(
&default_public_work_interaction_config_snapshots(),
)
.unwrap_or_else(|_| "[]".to_string())
}
/// 校验并归一后台公开作品互动配置 JSON。
#[cfg(any())]
pub fn normalize_public_work_interaction_config_json(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_public_work_interaction_config_json());
}
let configs = decode_public_work_interaction_config_snapshots(trimmed)?;
encode_public_work_interaction_config_snapshots(&configs)
}
/// 解析公开作品互动配置 JSON,并补齐缺失 sourceType 的默认项。
#[cfg(any())]
pub fn decode_public_work_interaction_config_snapshots(
input: &str,
) -> Result<Vec<PublicWorkInteractionConfigSnapshot>, String> {
let raw_entries = serde_json::from_str::<Vec<PublicWorkInteractionConfigResponse>>(input)
.map_err(|error| format!("作品互动配置 JSON 非法:{error}"))?;
if raw_entries.len() > PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT {
return Err(format!(
"作品互动配置最多允许 {} 条",
PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT
));
}
let defaults = default_public_work_interaction_config_snapshots();
let default_by_source = defaults
.iter()
.map(|item| (item.source_type.clone(), item.clone()))
.collect::<BTreeMap<_, _>>();
let mut overrides = BTreeMap::<String, PublicWorkInteractionConfigSnapshot>::new();
for (index, entry) in raw_entries.into_iter().enumerate() {
let source_type = entry.source_type.trim().to_string();
let Some(default_entry) = default_by_source.get(&source_type) else {
return Err(format!("第 {} 条作品类型非法:{}", index + 1, source_type));
};
if overrides.contains_key(&source_type) {
return Err(format!("作品互动配置 sourceType 重复:{source_type}"));
}
overrides.insert(
source_type.clone(),
PublicWorkInteractionConfigSnapshot {
source_type,
like_enabled: entry.like_enabled,
remix_enabled: entry.remix_enabled,
like_disabled_message: normalize_interaction_message(
entry.like_disabled_message,
&default_entry.like_disabled_message,
),
remix_disabled_message: normalize_interaction_message(
entry.remix_disabled_message,
&default_entry.remix_disabled_message,
),
},
);
}
Ok(defaults
.into_iter()
.map(|item| overrides.remove(&item.source_type).unwrap_or(item))
.collect())
}
#[cfg(any())]
fn normalize_interaction_message(value: String, fallback: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
/// 把公开作品互动领域快照编码为稳定 JSON。
#[cfg(any())]
pub fn encode_public_work_interaction_config_snapshots(
configs: &[PublicWorkInteractionConfigSnapshot],
) -> Result<String, String> {
let responses = configs
.iter()
.cloned()
.map(build_public_work_interaction_config_response)
.collect::<Vec<_>>();
serde_json::to_string_pretty(&responses)
.map_err(|error| format!("作品互动配置 JSON 序列化失败:{error}"))
}
/// 根据持久化 JSON 得到前台可消费的公开作品互动矩阵。
#[cfg(any())]
pub fn resolve_public_work_interaction_config_responses(
public_work_interactions_json: Option<&str>,
) -> Vec<PublicWorkInteractionConfigResponse> {
public_work_interactions_json
.and_then(|raw| decode_public_work_interaction_config_snapshots(raw).ok())
.unwrap_or_else(default_public_work_interaction_config_snapshots)
.into_iter()
.map(build_public_work_interaction_config_response)
.collect()
}
#[cfg(any())]
pub fn build_public_work_interaction_config_response(
config: PublicWorkInteractionConfigSnapshot,
) -> PublicWorkInteractionConfigResponse {
PublicWorkInteractionConfigResponse {
source_type: config.source_type,
like_enabled: config.like_enabled,
remix_enabled: config.remix_enabled,
like_disabled_message: config.like_disabled_message,
remix_disabled_message: config.remix_disabled_message,
}
}
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
#[cfg(any())]
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:linear-gradient(90deg,rgba(255,247,237,0.96) 0%,rgba(255,247,237,0.82) 48%,rgba(255,247,237,0.18) 100%),url('/creation-type-references/puzzle.webp') center/cover no-repeat;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 表字段持久化。
#[cfg(any())]
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。
#[cfg(any())]
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,输出已归一化的领域快照。
#[cfg(any())]
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 保持兼容。
#[cfg(any())]
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 快照。
#[cfg(any())]
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 两种后台填写习惯。
#[cfg(any())]
fn read_announcement_title(value: &Value) -> Option<String> {
read_string_field(value, &["title", "name"])
}
/// 读取公告 HTML 代码,兼容 htmlCode/html/code 三种后台填写习惯。
#[cfg(any())]
fn read_announcement_html_code(value: &Value) -> Option<String> {
read_string_field(value, &["htmlCode", "html", "code"])
}
/// 从 JSON 对象读取第一个非空字符串字段。
#[cfg(any())]
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。
#[cfg(any())]
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 或旧单条字段得到前台可渲染公告列表。
#[cfg(any())]
pub fn resolve_creation_entry_event_banner_responses(
event_banners_json: Option<&str>,
fallback_banner: &CreationEntryEventBannerSnapshot,
) -> Vec<CreationEntryEventBannerResponse> {
let banners = event_banners_json
.and_then(|raw| decode_creation_entry_event_banner_snapshots(raw).ok())
.filter(|banners| !banners.is_empty())
.unwrap_or_else(default_creation_entry_event_banner_snapshots);
if banners.is_empty() {
vec![fallback_banner.clone()]
} else {
banners
}
.into_iter()
.map(build_creation_entry_event_banner_response)
.collect()
}
/// 把领域公告快照转换为 HTTP 响应字段。
#[cfg(any())]
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 响应并转换为领域公告快照。
#[cfg(any())]
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。
#[cfg(any())]
fn normalize_banner_render_mode(value: &str) -> String {
if value.trim().eq_ignore_ascii_case("html") {
"html".to_string()
} else {
"structured".to_string()
}
}
/// 清理旧结构化 banner 文案字段,空值沿用平台默认文案。
#[cfg(any())]
fn normalize_banner_text(value: String, fallback: String) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
fallback
} else {
trimmed.to_string()
}
}
/// 校验 HTML 公告片段,只允许交给前端沙箱 iframe 展示。
#[cfg(any())]
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))
}
#[cfg(any())]
pub fn default_creation_entry_type_snapshots(
updated_at_micros: i64,
) -> Vec<CreationEntryTypeSnapshot> {
vec![
build_default_creation_entry_type_snapshot(
"rpg",
"文字冒险",
"经典 RPG 体验",
"可创建",
"/creation-type-references/rpg.webp",
true,
true,
10,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"big-fish",
"摸鱼",
"轻量闯关玩法",
"可创建",
"/creation-type-references/big-fish.webp",
false,
true,
20,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"puzzle",
"拼图",
"拼图关卡创作",
"可创建",
"/creation-type-references/puzzle.webp",
true,
true,
30,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"puzzle-clear",
"拼消消",
"拼接消除玩法",
"可创建",
"/creation-type-references/puzzle.webp",
true,
true,
46,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"wooden-fish",
"敲木鱼",
"点击祈福轻玩法",
"可创建",
"/wooden-fish/default-hit-object.png",
true,
true,
47,
"festival",
"节日主题",
30,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"square-hole",
"方洞",
"形状投放挑战",
"可创建",
"/creation-type-references/square-hole.webp",
false,
true,
50,
"material",
"材质工艺",
60,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"visual-novel",
"视觉小说",
"分支叙事体验",
"敬请期待",
"/creation-type-references/visual-novel.webp",
true,
false,
60,
"scene",
"生活场景",
50,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"airp",
"AI RPG",
"原生角色扮演",
"即将开放",
"/creation-type-references/airp.webp",
true,
false,
70,
"character",
"角色创作",
40,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"creative-agent",
"智能体创作",
"对话式创作实验",
"内测",
"/creation-type-references/creative-agent.webp",
false,
true,
80,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"bark-battle",
"汪汪声浪",
"声控对战挑战",
"可创建",
"/creation-type-references/bark-battle.webp",
true,
true,
85,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"baby-object-match",
"宝贝识物",
"亲子识物分类",
"可创建",
"/child-motion-demo/picture-book-grass-stage.png",
true,
true,
90,
"character",
"角色创作",
40,
updated_at_micros,
),
]
}
#[allow(clippy::too_many_arguments)]
#[cfg(any())]
fn build_default_creation_entry_type_snapshot(
id: &str,
title: &str,
subtitle: &str,
badge: &str,
image_src: &str,
visible: bool,
open: bool,
sort_order: i32,
category_id: &str,
category_label: &str,
category_sort_order: i32,
updated_at_micros: i64,
) -> CreationEntryTypeSnapshot {
CreationEntryTypeSnapshot {
id: id.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
badge: badge.to_string(),
image_src: image_src.to_string(),
visible,
open,
sort_order,
category_id: category_id.to_string(),
category_label: category_label.to_string(),
category_sort_order,
updated_at_micros,
unified_creation_spec_json: default_unified_creation_spec_json(id),
}
}
#[cfg(any())]
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,
music_volume: snapshot.music_volume,
platform_theme: snapshot.platform_theme,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
#[cfg(any())]
pub fn build_runtime_browse_history_record(
snapshot: RuntimeBrowseHistorySnapshot,
) -> RuntimeBrowseHistoryRecord {
RuntimeBrowseHistoryRecord {
browse_history_id: snapshot.browse_history_id,
user_id: snapshot.user_id,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
theme_mode: snapshot.theme_mode,
author_display_name: snapshot.author_display_name,
visited_at: format_utc_micros(snapshot.visited_at_micros),
visited_at_micros: snapshot.visited_at_micros,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_dashboard_record(
snapshot: RuntimeProfileDashboardSnapshot,
) -> RuntimeProfileDashboardRecord {
RuntimeProfileDashboardRecord {
user_id: snapshot.user_id,
wallet_balance: snapshot.wallet_balance,
total_play_time_ms: snapshot.total_play_time_ms,
played_world_count: snapshot.played_world_count,
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
daily_free_points: build_runtime_profile_daily_free_points_record(
snapshot.daily_free_points,
),
}
}
pub fn build_runtime_profile_daily_free_points_record(
snapshot: RuntimeProfileDailyFreePointsSnapshot,
) -> RuntimeProfileDailyFreePointsRecord {
RuntimeProfileDailyFreePointsRecord {
day_key: snapshot.day_key,
granted_points: snapshot.granted_points,
remaining_points: snapshot.remaining_points,
resets_at: format_utc_micros(snapshot.resets_at_micros),
resets_at_micros: snapshot.resets_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
reset_points: snapshot.reset_points,
}
}
pub fn build_runtime_profile_wallet_ledger_entry_record(
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
) -> RuntimeProfileWalletLedgerEntryRecord {
RuntimeProfileWalletLedgerEntryRecord {
wallet_ledger_id: snapshot.wallet_ledger_id,
user_id: snapshot.user_id,
amount_delta: snapshot.amount_delta,
balance_after: snapshot.balance_after,
source_type: snapshot.source_type,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
metadata_json: snapshot.metadata_json,
}
}
pub fn build_runtime_profile_wallet_config_record(
snapshot: RuntimeProfileWalletConfigSnapshot,
) -> RuntimeProfileWalletConfigRecord {
let format_optional_audit_time = |micros: i64| {
if micros > 0 {
format_utc_micros(micros)
} else {
"-".to_string()
}
};
RuntimeProfileWalletConfigRecord {
config_id: snapshot.config_id,
initial_mud_points: snapshot.initial_mud_points,
created_by: snapshot.created_by,
created_at: format_optional_audit_time(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at: format_optional_audit_time(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
daily_free_points_per_day: snapshot.daily_free_points_per_day,
}
}
pub fn build_runtime_profile_recharge_center_record(
snapshot: RuntimeProfileRechargeCenterSnapshot,
) -> RuntimeProfileRechargeCenterRecord {
RuntimeProfileRechargeCenterRecord {
user_id: snapshot.user_id,
wallet_balance: snapshot.wallet_balance,
membership: build_runtime_profile_membership_record(snapshot.membership),
point_products: snapshot
.point_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
membership_products: snapshot
.membership_products
.into_iter()
.map(build_runtime_profile_recharge_product_record)
.collect(),
benefits: snapshot
.benefits
.into_iter()
.map(build_runtime_profile_membership_benefit_record)
.collect(),
latest_order: snapshot
.latest_order
.map(build_runtime_profile_recharge_order_record),
has_points_recharged: snapshot.has_points_recharged,
daily_free_points: build_runtime_profile_daily_free_points_record(
snapshot.daily_free_points,
),
}
}
pub fn build_runtime_profile_recharge_product_record(
snapshot: RuntimeProfileRechargeProductSnapshot,
) -> RuntimeProfileRechargeProductRecord {
RuntimeProfileRechargeProductRecord {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: snapshot.kind,
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
membership_period_points: snapshot.membership_period_points,
membership_period_days: snapshot.membership_period_days,
membership_queue_limit: snapshot.membership_queue_limit,
membership_discount_bps: snapshot.membership_discount_bps,
}
}
pub fn build_runtime_profile_recharge_product_config_record(
snapshot: RuntimeProfileRechargeProductConfigSnapshot,
) -> RuntimeProfileRechargeProductConfigRecord {
RuntimeProfileRechargeProductConfigRecord {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: snapshot.kind,
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
membership_period_points: snapshot.membership_period_points,
membership_period_days: snapshot.membership_period_days,
membership_queue_limit: snapshot.membership_queue_limit,
membership_discount_bps: snapshot.membership_discount_bps,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_membership_benefit_record(
snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> RuntimeProfileMembershipBenefitRecord {
RuntimeProfileMembershipBenefitRecord {
benefit_name: snapshot.benefit_name,
normal_value: snapshot.normal_value,
month_value: snapshot.month_value,
season_value: snapshot.season_value,
year_value: snapshot.year_value,
starter_value: snapshot.starter_value,
basic_value: snapshot.basic_value,
pro_value: snapshot.pro_value,
ultimate_value: snapshot.ultimate_value,
}
}
pub fn build_runtime_profile_membership_record(
snapshot: RuntimeProfileMembershipSnapshot,
) -> RuntimeProfileMembershipRecord {
RuntimeProfileMembershipRecord {
user_id: snapshot.user_id,
status: snapshot.status,
tier: snapshot.tier,
started_at: snapshot.started_at_micros.map(format_utc_micros),
started_at_micros: snapshot.started_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
expires_at_micros: snapshot.expires_at_micros,
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
cycle_started_at: snapshot.cycle_started_at_micros.map(format_utc_micros),
cycle_started_at_micros: snapshot.cycle_started_at_micros,
cycle_resets_at: snapshot.cycle_resets_at_micros.map(format_utc_micros),
cycle_resets_at_micros: snapshot.cycle_resets_at_micros,
cycle_granted_points: snapshot.cycle_granted_points,
cycle_remaining_points: snapshot.cycle_remaining_points,
cycle_period_days: snapshot.cycle_period_days,
}
}
pub fn build_runtime_profile_recharge_order_record(
snapshot: RuntimeProfileRechargeOrderSnapshot,
) -> RuntimeProfileRechargeOrderRecord {
RuntimeProfileRechargeOrderRecord {
order_id: snapshot.order_id,
user_id: snapshot.user_id,
product_id: snapshot.product_id,
product_title: snapshot.product_title,
kind: snapshot.kind,
amount_cents: snapshot.amount_cents,
status: snapshot.status,
payment_channel: snapshot.payment_channel,
paid_at: snapshot.paid_at_micros.map(format_utc_micros),
paid_at_micros: snapshot.paid_at_micros,
provider_transaction_id: snapshot.provider_transaction_id,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
points_delta: snapshot.points_delta,
membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros),
membership_expires_at_micros: snapshot.membership_expires_at_micros,
expired_at: snapshot.expired_at_micros.map(format_utc_micros),
expired_at_micros: snapshot.expired_at_micros,
expiration_checked_at: snapshot.expiration_checked_at_micros.map(format_utc_micros),
expiration_checked_at_micros: snapshot.expiration_checked_at_micros,
expiration_provider_state: snapshot.expiration_provider_state,
expiration_last_error: snapshot.expiration_last_error,
}
}
pub fn build_runtime_profile_feedback_submission_record(
snapshot: RuntimeProfileFeedbackSubmissionSnapshot,
) -> Result<RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileFieldError> {
let evidence_items = serde_json::from_str::<Vec<RuntimeProfileFeedbackEvidenceSnapshot>>(
&snapshot.evidence_json,
)
.map_err(|_| RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)?
.into_iter()
.map(|item| RuntimeProfileFeedbackEvidenceRecord {
evidence_id: item.evidence_id,
file_name: item.file_name,
content_type: item.content_type,
size_bytes: item.size_bytes,
})
.collect();
Ok(RuntimeProfileFeedbackSubmissionRecord {
feedback_id: snapshot.feedback_id,
user_id: snapshot.user_id,
description: snapshot.description,
contact_phone: snapshot.contact_phone,
evidence_items,
status: snapshot.status,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
})
}
pub fn build_runtime_referral_invite_center_record(
snapshot: RuntimeReferralInviteCenterSnapshot,
) -> RuntimeReferralInviteCenterRecord {
RuntimeReferralInviteCenterRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
invite_link_path: snapshot.invite_link_path,
invited_count: snapshot.invited_count,
rewarded_invite_count: snapshot.rewarded_invite_count,
today_inviter_reward_count: snapshot.today_inviter_reward_count,
today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining,
reward_points: snapshot.reward_points,
invited_users: snapshot
.invited_users
.into_iter()
.map(|user| RuntimeReferralInvitedUserRecord {
user_id: user.user_id,
display_name: user.display_name,
avatar_url: user.avatar_url,
bound_at: format_utc_micros(user.bound_at_micros),
bound_at_micros: user.bound_at_micros,
})
.collect(),
has_redeemed_code: snapshot.has_redeemed_code,
bound_inviter_user_id: snapshot.bound_inviter_user_id,
bound_at: snapshot.bound_at_micros.map(format_utc_micros),
bound_at_micros: snapshot.bound_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_referral_redeem_record(
snapshot: RuntimeReferralRedeemSnapshot,
) -> RuntimeReferralRedeemRecord {
RuntimeReferralRedeemRecord {
center: build_runtime_referral_invite_center_record(snapshot.center),
invitee_reward_granted: snapshot.invitee_reward_granted,
inviter_reward_granted: snapshot.inviter_reward_granted,
invitee_balance_after: snapshot.invitee_balance_after,
inviter_balance_after: snapshot.inviter_balance_after,
}
}
pub fn build_runtime_profile_reward_code_redeem_record(
snapshot: RuntimeProfileRewardCodeRedeemSnapshot,
) -> RuntimeProfileRewardCodeRedeemRecord {
RuntimeProfileRewardCodeRedeemRecord {
wallet_balance: snapshot.wallet_balance,
amount_granted: snapshot.amount_granted,
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
}
}
pub fn runtime_profile_beijing_day_key(now_micros: i64) -> i64 {
now_micros
.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS)
.div_euclid(PROFILE_RUNTIME_DAY_MICROS)
}
/// 从 YYYY-MM-DD 解析分析业务日 date_key。
///
/// 这里故意不引入时区库:date_key 本身就是“北京时间日历日期自 Unix 纪元起的天数”。
pub fn parse_analytics_calendar_date_key(
calendar_date: &str,
) -> Result<i64, RuntimeProfileFieldError> {
let (year, month, day) = parse_calendar_date_parts(calendar_date)?;
validate_calendar_date(year, month, day)?;
let date_key = days_from_civil(year, month, day);
validate_analytics_date_dimension_date_key(date_key)?;
Ok(date_key)
}
/// 校验分析日期维表 date_key 是否位于业务允许范围内。
///
/// 裸 i64 date_key 可由 reducer 直接传入,因此在进入日历算法前先限制范围,避免极端输入
/// 生成无意义日期或触发整数边界风险。
pub fn validate_analytics_date_dimension_date_key(
date_key: i64,
) -> Result<(), RuntimeProfileFieldError> {
let min_date_key = days_from_civil(2000, 1, 1);
let max_date_key = days_from_civil(2100, 12, 31);
if date_key < min_date_key || date_key > max_date_key {
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
}
Ok(())
}
pub fn build_analytics_date_dimension_from_date_key(
date_key: i64,
) -> AnalyticsDateDimensionSnapshot {
let (year, month, day) = civil_from_days(date_key);
let weekday = weekday_from_date_key(date_key);
let iso_week_key = iso_week_key(year, month, day, weekday);
let week_start_date_key = date_key - i64::from(weekday - 1);
let week_end_date_key = week_start_date_key + 6;
let month_start_date_key = days_from_civil(year, month, 1);
let month_end_date_key = days_from_civil(year, month, days_in_month(year, month));
let quarter = (month - 1) / 3 + 1;
let quarter_start_month = (quarter - 1) * 3 + 1;
let quarter_end_month = quarter_start_month + 2;
let quarter_start_date_key = days_from_civil(year, quarter_start_month, 1);
let quarter_end_date_key = days_from_civil(
year,
quarter_end_month,
days_in_month(year, quarter_end_month),
);
let year_start_date_key = days_from_civil(year, 1, 1);
let year_end_date_key = days_from_civil(year, 12, 31);
AnalyticsDateDimensionSnapshot {
date_key,
calendar_date: format!("{year:04}-{month:02}-{day:02}"),
weekday,
iso_week_key,
week_start_date_key,
week_end_date_key,
month_key: year * 100 + i32::from(month),
month_start_date_key,
month_end_date_key,
quarter_key: year * 10 + i32::from(quarter),
quarter_start_date_key,
quarter_end_date_key,
year_key: year,
year_start_date_key,
year_end_date_key,
}
}
fn parse_calendar_date_parts(
calendar_date: &str,
) -> Result<(i32, u8, u8), RuntimeProfileFieldError> {
let mut parts = calendar_date.trim().split('-');
let year = parts
.next()
.and_then(|value| value.parse::<i32>().ok())
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
let month = parts
.next()
.and_then(|value| value.parse::<u8>().ok())
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
let day = parts
.next()
.and_then(|value| value.parse::<u8>().ok())
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
if parts.next().is_some() {
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
}
Ok((year, month, day))
}
fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimeProfileFieldError> {
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
}
Ok(())
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn weekday_from_date_key(date_key: i64) -> u8 {
// 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。
(date_key + 3).rem_euclid(7) as u8 + 1
}
fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 {
let ordinal = ordinal_day(year, month, day);
let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7);
let iso_year = if week < 1 {
year - 1
} else if week > iso_weeks_in_year(year) {
year + 1
} else {
year
};
let iso_week = if week < 1 {
iso_weeks_in_year(year - 1)
} else if week > iso_weeks_in_year(year) {
1
} else {
week
};
iso_year * 100 + iso_week
}
fn ordinal_day(year: i32, month: u8, day: u8) -> u16 {
(1..month)
.map(|current_month| u16::from(days_in_month(year, current_month)))
.sum::<u16>()
+ u16::from(day)
}
fn iso_weeks_in_year(year: i32) -> i32 {
let jan_first_weekday = weekday_from_date_key(days_from_civil(year, 1, 1));
if jan_first_weekday == 4 || (jan_first_weekday == 3 && is_leap_year(year)) {
53
} else {
52
}
}
fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
// 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。
let adjusted_year = year - if month <= 2 { 1 } else { 0 };
let era = adjusted_year.div_euclid(400);
let year_of_era = adjusted_year - era * 400;
let month = i32::from(month);
let day = i32::from(day);
let month_prime = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
i64::from(era * 146_097 + day_of_era - 719_468)
}
fn civil_from_days(date_key: i64) -> (i32, u8, u8) {
// 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。
let z = date_key + 719_468;
let era = z.div_euclid(146_097);
let day_of_era = z - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524
- day_of_era / 146_096)
.div_euclid(365);
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2).div_euclid(153);
let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year as i32, month as u8, day as u8)
}
pub fn build_default_runtime_profile_task_config(
updated_at_micros: i64,
updated_by: String,
) -> RuntimeProfileTaskConfigSnapshot {
RuntimeProfileTaskConfigSnapshot {
task_id: PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
title: PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN.to_string(),
description: String::new(),
event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
cycle: RuntimeProfileTaskCycle::Daily,
scope_kind: RuntimeTrackingScopeKind::User,
threshold: PROFILE_TASK_DEFAULT_THRESHOLD,
reward_points: PROFILE_TASK_DEFAULT_REWARD_POINTS,
enabled: true,
sort_order: 10,
created_by: updated_by.clone(),
created_at_micros: updated_at_micros,
updated_by,
updated_at_micros,
}
}
pub fn resolve_runtime_profile_task_status(
enabled: bool,
progress_count: u32,
threshold: u32,
claimed: bool,
) -> RuntimeProfileTaskStatus {
if !enabled {
return RuntimeProfileTaskStatus::Disabled;
}
if claimed {
return RuntimeProfileTaskStatus::Claimed;
}
if progress_count >= threshold {
RuntimeProfileTaskStatus::Claimable
} else {
RuntimeProfileTaskStatus::Incomplete
}
}
pub fn build_runtime_profile_task_progress_id(
user_id: &str,
task_id: &str,
day_key: i64,
) -> String {
format!("{}:{}:{}", user_id.trim(), task_id.trim(), day_key)
}
pub fn build_runtime_profile_task_claim_id(user_id: &str, task_id: &str, day_key: i64) -> String {
build_runtime_profile_task_progress_id(user_id, task_id, day_key)
}
pub fn build_runtime_profile_task_reward_ledger_id(
user_id: &str,
task_id: &str,
day_key: i64,
) -> String {
format!(
"task-reward:{}:{}:{}",
user_id.trim(),
task_id.trim(),
day_key
)
}
pub fn build_runtime_tracking_event_id(
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
occurred_at_micros: i64,
) -> String {
format!(
"tracking:{}:{}:{}:{}",
event_key.trim(),
scope_kind.as_str(),
scope_id.trim(),
occurred_at_micros
)
}
pub fn build_runtime_tracking_daily_stat_id(
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
day_key: i64,
) -> String {
format!(
"tracking-stat:{}:{}:{}:{}",
event_key.trim(),
scope_kind.as_str(),
scope_id.trim(),
day_key
)
}
pub fn aggregate_runtime_tracking_daily_stats(
stats: Vec<RuntimeAnalyticsDailyStatSnapshot>,
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
granularity: AnalyticsGranularity,
) -> Vec<AnalyticsBucketMetric> {
let mut buckets: BTreeMap<(String, i64, i64), u64> = BTreeMap::new();
let event_key = event_key.trim();
let scope_id = scope_id.trim();
for stat in stats {
if stat.event_key.trim() != event_key
|| stat.scope_kind != scope_kind
|| stat.scope_id.trim() != scope_id
{
continue;
}
let dimension = build_analytics_date_dimension_from_date_key(stat.day_key);
let (bucket_key, bucket_start_date_key, bucket_end_date_key) =
analytics_bucket_for_dimension(&dimension, granularity);
*buckets
.entry((bucket_key, bucket_start_date_key, bucket_end_date_key))
.or_insert(0) += u64::from(stat.count);
}
buckets
.into_iter()
.map(
|((bucket_key, bucket_start_date_key, bucket_end_date_key), value)| {
AnalyticsBucketMetric {
bucket_key,
bucket_start_date_key,
bucket_end_date_key,
value,
}
},
)
.collect()
}
fn analytics_bucket_for_dimension(
dimension: &AnalyticsDateDimensionSnapshot,
granularity: AnalyticsGranularity,
) -> (String, i64, i64) {
match granularity {
AnalyticsGranularity::Day => (
dimension.calendar_date.clone(),
dimension.date_key,
dimension.date_key,
),
AnalyticsGranularity::Week => (
dimension.iso_week_key.to_string(),
dimension.week_start_date_key,
dimension.week_end_date_key,
),
AnalyticsGranularity::Month => (
dimension.month_key.to_string(),
dimension.month_start_date_key,
dimension.month_end_date_key,
),
AnalyticsGranularity::Quarter => (
dimension.quarter_key.to_string(),
dimension.quarter_start_date_key,
dimension.quarter_end_date_key,
),
AnalyticsGranularity::Year => (
dimension.year_key.to_string(),
dimension.year_start_date_key,
dimension.year_end_date_key,
),
}
}
pub fn build_runtime_profile_task_config_record(
snapshot: RuntimeProfileTaskConfigSnapshot,
) -> RuntimeProfileTaskConfigRecord {
RuntimeProfileTaskConfigRecord {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: snapshot.cycle,
scope_kind: snapshot.scope_kind,
threshold: snapshot.threshold,
reward_points: snapshot.reward_points,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_item_record(
snapshot: RuntimeProfileTaskItemSnapshot,
) -> RuntimeProfileTaskItemRecord {
RuntimeProfileTaskItemRecord {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: snapshot.cycle,
threshold: snapshot.threshold,
progress_count: snapshot.progress_count,
reward_points: snapshot.reward_points,
status: snapshot.status,
day_key: snapshot.day_key,
claimed_at: snapshot.claimed_at_micros.map(format_utc_micros),
claimed_at_micros: snapshot.claimed_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_center_record(
snapshot: RuntimeProfileTaskCenterSnapshot,
) -> RuntimeProfileTaskCenterRecord {
RuntimeProfileTaskCenterRecord {
user_id: snapshot.user_id,
day_key: snapshot.day_key,
wallet_balance: snapshot.wallet_balance,
tasks: snapshot
.tasks
.into_iter()
.map(build_runtime_profile_task_item_record)
.collect(),
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_claim_record(
snapshot: RuntimeProfileTaskClaimSnapshot,
) -> RuntimeProfileTaskClaimRecord {
RuntimeProfileTaskClaimRecord {
user_id: snapshot.user_id,
task_id: snapshot.task_id,
day_key: snapshot.day_key,
reward_points: snapshot.reward_points,
wallet_balance: snapshot.wallet_balance,
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
center: build_runtime_profile_task_center_record(snapshot.center),
}
}
pub fn build_runtime_profile_redeem_code_record(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> RuntimeProfileRedeemCodeRecord {
RuntimeProfileRedeemCodeRecord {
code: snapshot.code,
mode: snapshot.mode,
reward_points: snapshot.reward_points,
max_uses: snapshot.max_uses,
global_used_count: snapshot.global_used_count,
enabled: snapshot.enabled,
allowed_user_ids: snapshot.allowed_user_ids,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
starts_at: snapshot.starts_at_micros.map(format_utc_micros),
starts_at_micros: snapshot.starts_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
expires_at_micros: snapshot.expires_at_micros,
}
}
pub fn build_runtime_profile_code_operation_record(
snapshot: RuntimeProfileCodeOperationSnapshot,
) -> RuntimeProfileCodeOperationRecord {
RuntimeProfileCodeOperationRecord {
operation_id: snapshot.operation_id,
code_kind: snapshot.code_kind,
code: snapshot.code,
action: snapshot.action,
operator_user_id: snapshot.operator_user_id,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
}
}
pub fn build_runtime_profile_invite_code_record(
snapshot: RuntimeProfileInviteCodeSnapshot,
) -> RuntimeProfileInviteCodeRecord {
let status = crate::commands::resolve_runtime_profile_invite_code_status(
snapshot.starts_at_micros,
snapshot.expires_at_micros,
snapshot.updated_at_micros,
);
RuntimeProfileInviteCodeRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
starts_at: snapshot.starts_at_micros.map(format_utc_micros),
starts_at_micros: snapshot.starts_at_micros,
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
expires_at_micros: snapshot.expires_at_micros,
status,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
#[cfg(any())]
pub fn build_runtime_profile_played_world_record(
snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord {
RuntimeProfilePlayedWorldRecord {
played_world_id: snapshot.played_world_id,
user_id: snapshot.user_id,
world_key: snapshot.world_key,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_type: snapshot.world_type,
world_title: snapshot.world_title,
world_subtitle: snapshot.world_subtitle,
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
first_played_at_micros: snapshot.first_played_at_micros,
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
last_played_at_micros: snapshot.last_played_at_micros,
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
}
}
#[cfg(any())]
pub fn build_runtime_profile_play_stats_record(
snapshot: RuntimeProfilePlayStatsSnapshot,
) -> RuntimeProfilePlayStatsRecord {
RuntimeProfilePlayStatsRecord {
user_id: snapshot.user_id,
total_play_time_ms: snapshot.total_play_time_ms,
played_works: snapshot
.played_works
.into_iter()
.map(build_runtime_profile_played_world_record)
.collect(),
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
#[cfg(any())]
pub fn build_runtime_snapshot_record(
snapshot: RuntimeSnapshot,
) -> Result<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story = parse_optional_json_value(
snapshot.current_story_json.as_deref(),
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)?;
Ok(RuntimeSnapshotRecord {
user_id: snapshot.user_id,
version: snapshot.version,
saved_at: format_utc_micros(snapshot.saved_at_micros),
saved_at_micros: snapshot.saved_at_micros,
bottom_tab: snapshot.bottom_tab,
game_state,
current_story,
game_state_json: snapshot.game_state_json,
current_story_json: snapshot.current_story_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
})
}
#[cfg(any())]
pub fn build_runtime_profile_save_archive_record(
snapshot: RuntimeProfileSaveArchiveSnapshot,
) -> Result<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story = parse_optional_json_value(
snapshot.current_story_json.as_deref(),
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)?;
Ok(RuntimeProfileSaveArchiveRecord {
archive_id: snapshot.archive_id,
user_id: snapshot.user_id,
world_key: snapshot.world_key,
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
world_type: snapshot.world_type,
world_name: snapshot.world_name,
subtitle: snapshot.subtitle,
summary_text: snapshot.summary_text,
cover_image_src: snapshot.cover_image_src,
saved_at: format_utc_micros(snapshot.saved_at_micros),
saved_at_micros: snapshot.saved_at_micros,
bottom_tab: snapshot.bottom_tab,
game_state,
current_story,
game_state_json: snapshot.game_state_json,
current_story_json: snapshot.current_story_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
})
}
#[cfg(any())]
pub fn build_runtime_save_checkpoint_update(
input: RuntimeSaveCheckpointInput,
existing: RuntimeSnapshotRecord,
) -> Result<RuntimeSaveCheckpointSnapshotUpdate, RuntimeProfileFieldError> {
if is_non_persistent_runtime_snapshot(&existing.game_state) {
return Err(RuntimeProfileFieldError::NonPersistentRuntimeSnapshot);
}
let persisted_session_id =
read_runtime_json_string_field(&existing.game_state, "runtimeSessionId")
.ok_or(RuntimeProfileFieldError::MissingRuntimeSessionId)?;
if persisted_session_id != input.session_id {
return Err(RuntimeProfileFieldError::RuntimeSessionMismatch {
expected_session_id: persisted_session_id,
actual_session_id: input.session_id,
});
}
Ok(RuntimeSaveCheckpointSnapshotUpdate {
saved_at_micros: input.saved_at_micros,
bottom_tab: input.bottom_tab,
game_state: refresh_runtime_snapshot_play_time(
existing.game_state,
input.updated_at_micros,
),
current_story: existing.current_story,
updated_at_micros: input.updated_at_micros,
})
}
#[cfg(any())]
pub fn build_runtime_profile_played_world_id(user_id: &str, world_key: &str) -> String {
format!("{}:{}", user_id.trim(), world_key.trim())
}
#[cfg(any())]
pub fn build_runtime_profile_snapshot_wallet_ledger_id(
user_id: &str,
saved_at_micros: i64,
next_wallet_balance: u64,
) -> String {
format!(
"{}:{}:{}",
user_id.trim(),
saved_at_micros,
next_wallet_balance
)
}
#[cfg(any())]
pub fn build_runtime_profile_save_archive_id(user_id: &str, world_key: &str) -> String {
format!("{}:{}", user_id.trim(), world_key.trim())
}
pub fn build_runtime_profile_recharge_wallet_ledger_id(
user_id: &str,
created_at_micros: i64,
product_id: &str,
) -> String {
format!(
"{}:{}:{}",
user_id.trim(),
created_at_micros,
product_id.trim()
)
}
pub fn build_runtime_profile_recharge_order_id(
user_id: &str,
created_at_micros: i64,
product_id: &str,
) -> String {
// 微信支付 v3 的 out_trade_no 只接受较短的字母、数字和部分符号。
// 订单号同时作为本地 profile_recharge_order 主键,因此统一使用可支付渠道兼容的紧凑格式。
let timestamp = encode_runtime_profile_recharge_order_base36(created_at_micros.unsigned_abs());
let hash = hash_runtime_profile_recharge_order_key(user_id, product_id, created_at_micros);
format!("rcg{timestamp}{:010x}", hash & 0x0000_0003_ffff_ffff)
}
fn encode_runtime_profile_recharge_order_base36(mut value: u64) -> String {
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
if value == 0 {
return "0".to_string();
}
let mut buffer = Vec::new();
while value > 0 {
buffer.push(DIGITS[(value % 36) as usize] as char);
value /= 36;
}
buffer.iter().rev().collect()
}
fn hash_runtime_profile_recharge_order_key(
user_id: &str,
product_id: &str,
created_at_micros: i64,
) -> u64 {
let mut hash = 14_695_981_039_346_656_037u64;
for byte in user_id
.trim()
.as_bytes()
.iter()
.copied()
.chain([b':'])
.chain(product_id.trim().as_bytes().iter().copied())
.chain([b':'])
.chain(created_at_micros.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(1_099_511_628_211);
}
hash
}
pub fn resolve_runtime_profile_points_recharge_delta(
product: &RuntimeProfileRechargeProductSnapshot,
has_product_recharged: bool,
) -> u64 {
let bonus_points = if has_product_recharged {
0
} else {
product.bonus_points
};
product.points_amount.saturating_add(bonus_points)
}
pub fn resolve_runtime_profile_membership_purchase_update(
current_started_at_micros: Option<i64>,
current_expires_at_micros: Option<i64>,
purchased_at_micros: i64,
duration_days: u32,
) -> RuntimeProfileMembershipPurchaseUpdate {
let start_at_micros = current_expires_at_micros
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
.unwrap_or(purchased_at_micros);
let expires_at_micros = start_at_micros
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS));
RuntimeProfileMembershipPurchaseUpdate {
started_at_micros: current_started_at_micros.unwrap_or(purchased_at_micros),
expires_at_micros,
}
}
pub fn calculate_runtime_profile_recharge_refund_target_points(
order_points_delta: i64,
cumulative_success_refund_cents: u64,
order_amount_cents: u64,
) -> Result<u64, String> {
if order_amount_cents == 0 {
return Err("recharge refund order amount must be positive".to_string());
}
if cumulative_success_refund_cents > order_amount_cents {
return Err("recharge refund cumulative amount exceeds order amount".to_string());
}
if order_points_delta <= 0 || cumulative_success_refund_cents == 0 {
return Ok(0);
}
let order_points = order_points_delta as u64;
if cumulative_success_refund_cents == order_amount_cents {
return Ok(order_points);
}
let target = u128::from(order_points)
.saturating_mul(u128::from(cumulative_success_refund_cents))
/ u128::from(order_amount_cents);
u64::try_from(target).map_err(|_| "recharge refund target points overflow".to_string())
}
pub fn resolve_runtime_profile_recharge_refund_recovery(
outstanding_points: u64,
wallet_total_points: u64,
daily_free_points: u64,
membership_limited_points: u64,
) -> (u64, u64) {
resolve_runtime_profile_recharge_refund_recovery_with_holds(
outstanding_points,
wallet_total_points,
daily_free_points,
membership_limited_points,
0,
)
}
pub fn resolve_runtime_profile_recharge_refund_recovery_with_holds(
outstanding_points: u64,
wallet_total_points: u64,
daily_free_points: u64,
membership_limited_points: u64,
unrelated_held_points: u64,
) -> (u64, u64) {
let permanent_points = wallet_total_points
.saturating_sub(daily_free_points)
.saturating_sub(membership_limited_points);
let available_permanent_points = permanent_points.saturating_sub(unrelated_held_points);
let recoverable_points = outstanding_points.min(available_permanent_points);
(
recoverable_points,
outstanding_points.saturating_sub(recoverable_points),
)
}
pub fn validate_runtime_profile_recharge_refund_hold_capacity(
required_points: u64,
permanent_points: u64,
active_held_points: u64,
) -> Result<u64, String> {
let available_points = permanent_points.saturating_sub(active_held_points);
if required_points > available_points {
return Err(format!(
"可追回永久泥点不足:需要 {required_points},当前可用 {available_points}"
));
}
Ok(available_points)
}
pub fn resolve_runtime_profile_recharge_refund_hold_points(
incremental_target_recovery_points: u64,
refund_cents: u64,
remaining_refundable_cents: u64,
) -> u64 {
// A concurrent external partial refund can move the cumulative floor boundary by one point.
let concurrency_buffer = u64::from(refund_cents < remaining_refundable_cents);
incremental_target_recovery_points.saturating_add(concurrency_buffer)
}
pub fn validate_runtime_profile_wallet_debit_restrictions(
amount_delta: i64,
source_type: RuntimeProfileWalletLedgerSourceType,
manual_frozen: bool,
refund_debt_frozen: bool,
) -> Result<(), String> {
if amount_delta >= 0
|| source_type == RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery
{
return Ok(());
}
if manual_frozen {
return Err("账户已被人工冻结,暂不可继续消费泥点".to_string());
}
if refund_debt_frozen {
return Err("账户存在充值退款权益欠款,暂不可继续消费泥点".to_string());
}
Ok(())
}
pub fn validate_runtime_profile_wallet_debit_availability(
wallet_total_points: u64,
active_held_points: u64,
debit_points: u64,
) -> Result<u64, String> {
let spendable_points = wallet_total_points.saturating_sub(active_held_points);
if debit_points > spendable_points {
return Err(format!(
"可消费泥点不足:需要 {debit_points},扣除退款占用后可用 {spendable_points}"
));
}
Ok(spendable_points.saturating_sub(debit_points))
}
pub fn build_runtime_profile_recharge_refund_settlement_plan(
current_successful_refund_count: u32,
current_cumulative_success_refund_cents: u64,
current_target_recovery_points: u64,
refund_cents: u64,
order_amount_cents: u64,
order_points_delta: i64,
) -> Result<RuntimeProfileRechargeRefundSettlementPlan, String> {
if current_successful_refund_count >= 50 {
return Err("recharge refund count exceeds limit".to_string());
}
let cumulative_success_refund_cents = current_cumulative_success_refund_cents
.checked_add(refund_cents)
.ok_or_else(|| "recharge refund cumulative amount overflow".to_string())?;
if cumulative_success_refund_cents > order_amount_cents {
return Err("recharge refund cumulative amount exceeds order amount".to_string());
}
let target_recovery_points = calculate_runtime_profile_recharge_refund_target_points(
order_points_delta,
cumulative_success_refund_cents,
order_amount_cents,
)?;
if target_recovery_points < current_target_recovery_points {
return Err("recharge refund target points regressed".to_string());
}
Ok(RuntimeProfileRechargeRefundSettlementPlan {
successful_refund_count: current_successful_refund_count.saturating_add(1),
cumulative_success_refund_cents,
order_fully_refunded: cumulative_success_refund_cents == order_amount_cents,
target_recovery_points,
incremental_target_recovery_points: target_recovery_points
.saturating_sub(current_target_recovery_points),
})
}
pub fn resolve_runtime_profile_recharge_refund_status_transition(
current: RuntimeProfileRechargeRefundStatus,
observed: RuntimeProfileRechargeRefundStatus,
) -> RuntimeProfileRechargeRefundStatusTransition {
use RuntimeProfileRechargeRefundStatus::{Abnormal, Closed, Processing, Success};
use RuntimeProfileRechargeRefundStatusTransition::{Advance, Conflict, Unchanged};
if current == observed {
return Unchanged;
}
match (current, observed) {
(Processing, Abnormal | Closed | Success) | (Abnormal, Closed | Success) => Advance,
(Success | Closed, _) | (Abnormal, Processing) => Conflict,
_ => Conflict,
}
}
pub fn resolve_runtime_profile_recharge_refund_hold_status(
current: RuntimeProfileRechargeRefundHoldStatus,
provider_status: RuntimeProfileRechargeRefundStatus,
) -> RuntimeProfileRechargeRefundHoldStatus {
match provider_status {
RuntimeProfileRechargeRefundStatus::Success => {
RuntimeProfileRechargeRefundHoldStatus::Settled
}
RuntimeProfileRechargeRefundStatus::Closed
if current != RuntimeProfileRechargeRefundHoldStatus::Settled =>
{
RuntimeProfileRechargeRefundHoldStatus::Released
}
RuntimeProfileRechargeRefundStatus::Processing
| RuntimeProfileRechargeRefundStatus::Abnormal
| RuntimeProfileRechargeRefundStatus::Closed => current,
}
}
pub fn build_runtime_profile_invite_code(user_id: &str, salt: u32) -> String {
let mut hash = 14_695_981_039_346_656_037u64;
for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) {
hash ^= byte as u64;
hash = hash.wrapping_mul(1_099_511_628_211);
}
format!("SY{:08X}", hash as u32)
}
pub fn build_runtime_profile_invite_link_path(invite_code: &str) -> String {
format!("/?inviteCode={}", invite_code.trim())
}
pub fn runtime_profile_day_start_micros(now_micros: i64) -> i64 {
now_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS) * PROFILE_RUNTIME_DAY_MICROS
}
pub fn should_grant_runtime_profile_inviter_reward(today_inviter_reward_count: u32) -> bool {
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
}
pub fn build_runtime_profile_referral_invitee_ledger_id(
invitee_user_id: &str,
updated_at_micros: i64,
) -> String {
format!("invitee:{}:{}", invitee_user_id.trim(), updated_at_micros)
}
pub fn build_runtime_profile_referral_inviter_ledger_id(
inviter_user_id: &str,
updated_at_micros: i64,
) -> String {
format!("inviter:{}:{}", inviter_user_id.trim(), updated_at_micros)
}
pub fn validate_runtime_profile_redeem_code_usage(
code: &RuntimeProfileRedeemCodeSnapshot,
user_id: &str,
user_used_count: u32,
redeemed_at_micros: i64,
) -> Result<(), RuntimeProfileFieldError> {
if !code.enabled {
return Err(RuntimeProfileFieldError::RedeemCodeDisabled);
}
if code.reward_points == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
}
crate::commands::validate_runtime_profile_redeem_code_redeem_time(
code.starts_at_micros,
code.expires_at_micros,
redeemed_at_micros,
)?;
match code.mode {
RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Unique if user_used_count >= 1 => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Private => {
if !code.allowed_user_ids.iter().any(|item| item == user_id) {
return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser);
}
if user_used_count >= 1 {
return Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted);
}
Ok(())
}
_ => Ok(()),
}
}
pub fn build_runtime_profile_redeem_code_usage_id(
code: &str,
user_id: &str,
redeemed_at_micros: i64,
sequence: u32,
) -> String {
format!(
"redeem:{}:{}:{}:{}",
code.trim(),
user_id.trim(),
redeemed_at_micros,
sequence
)
}
pub fn build_runtime_profile_redeem_code_ledger_id(usage_id: &str) -> String {
format!("{}:ledger", usage_id.trim())
}
pub fn convert_runtime_profile_wallet_unsigned_delta(
amount_delta: u64,
) -> Result<i64, RuntimeProfileFieldError> {
i64::try_from(amount_delta).map_err(|_| RuntimeProfileFieldError::WalletAmountOverflow)
}
pub fn calculate_runtime_profile_wallet_balance(
previous_balance: u64,
amount_delta: i64,
) -> Result<u64, RuntimeProfileFieldError> {
if amount_delta >= 0 {
previous_balance
.checked_add(amount_delta as u64)
.ok_or(RuntimeProfileFieldError::WalletBalanceOverflow)
} else {
previous_balance
.checked_sub(amount_delta.unsigned_abs())
.ok_or(RuntimeProfileFieldError::InsufficientWalletBalance)
}
}
#[cfg(any())]
pub fn refresh_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
let Some(game_state_object) = game_state.as_object_mut() else {
return game_state;
};
let now_text = format_utc_micros(now_micros);
let Some(runtime_stats) = game_state_object
.get_mut("runtimeStats")
.and_then(Value::as_object_mut)
else {
game_state_object.insert(
"runtimeStats".to_string(),
serde_json::json!({
"playTimeMs": 0,
"lastPlayTickAt": now_text,
"hostileNpcsDefeated": 0,
"questsAccepted": 0,
"itemsUsed": 0,
"scenesTraveled": 0,
}),
);
return game_state;
};
let current_play_time = runtime_stats
.get("playTimeMs")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let elapsed_ms = runtime_stats
.get("lastPlayTickAt")
.and_then(Value::as_str)
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
.map(offset_datetime_to_unix_micros)
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
.unwrap_or(0.0);
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
// checkpoint 只刷新服务端已有 runtimeStats 的时间水位,不接收浏览器上传的剧情、背包或战斗真相。
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
game_state
}
#[cfg(any())]
pub fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
read_runtime_json_string_field_from_map(game_state, "runtimeMode").as_deref(),
Some("preview") | Some("test")
)
}
#[cfg(any())]
pub fn resolve_runtime_profile_world_snapshot_meta(
game_state: Option<&serde_json::Map<String, Value>>,
) -> Option<RuntimeProfileWorldSnapshotMeta> {
let game_state = game_state?;
let custom_world_profile = game_state
.get("customWorldProfile")
.and_then(Value::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let profile_id = read_runtime_json_string_field_from_map(custom_world_profile, "id");
let world_title = read_runtime_json_string_field_from_map(custom_world_profile, "name")
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"));
if profile_id.is_some() || world_title.is_some() {
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
return Some(RuntimeProfileWorldSnapshotMeta {
world_key: profile_id
.as_ref()
.map(|profile_id| format!("custom:{profile_id}"))
.unwrap_or_else(|| format!("custom:{world_title}")),
owner_user_id: None,
profile_id,
world_type: Some("CUSTOM".to_string()),
world_title,
world_subtitle: read_runtime_json_string_field_from_map(
custom_world_profile,
"summary",
)
.or_else(|| {
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
})
.unwrap_or_default(),
});
}
}
let world_type = read_runtime_json_string_field_from_map(game_state, "worldType")?;
let current_scene_preset = game_state
.get("currentScenePreset")
.and_then(Value::as_object);
Some(RuntimeProfileWorldSnapshotMeta {
world_key: format!("builtin:{world_type}"),
owner_user_id: None,
profile_id: None,
world_type: Some(world_type.clone()),
world_title: current_scene_preset
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "name"))
.unwrap_or_else(|| build_runtime_builtin_world_title(&world_type)),
world_subtitle: current_scene_preset
.and_then(|preset| {
read_runtime_json_string_field_from_map(preset, "summary")
.or_else(|| read_runtime_json_string_field_from_map(preset, "description"))
})
.unwrap_or_default(),
})
}
#[cfg(any())]
pub fn resolve_runtime_profile_save_archive_meta(
game_state: &Value,
current_story_json: Option<&str>,
) -> Option<RuntimeProfileSaveArchiveMeta> {
if is_non_persistent_runtime_snapshot(game_state) {
return None;
}
let game_state_object = game_state.as_object();
let world_meta = resolve_runtime_profile_world_snapshot_meta(game_state_object)?;
let story_engine_memory = game_state_object
.and_then(|state| state.get("storyEngineMemory"))
.and_then(Value::as_object);
let continue_game_digest = story_engine_memory
.and_then(|memory| read_runtime_json_string_field_from_map(memory, "continueGameDigest"));
let current_story_text = parse_optional_json_value(
current_story_json,
RuntimeProfileFieldError::InvalidCurrentStoryJson,
)
.ok()
.flatten()
.and_then(|story| story.as_object().cloned())
.and_then(|story| read_runtime_json_string_field_from_map(&story, "text"));
let custom_world_profile = game_state_object
.and_then(|state| state.get("customWorldProfile"))
.and_then(Value::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let world_name = read_runtime_json_string_field_from_map(custom_world_profile, "name")
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"))
.unwrap_or_else(|| world_meta.world_title.clone());
let subtitle = read_runtime_json_string_field_from_map(custom_world_profile, "summary")
.or_else(|| {
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
})
.unwrap_or_else(|| world_meta.world_subtitle.clone());
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if subtitle.is_empty() {
None
} else {
Some(subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
return Some(RuntimeProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name,
subtitle,
summary_text,
cover_image_src: read_runtime_json_string_field_from_map(
custom_world_profile,
"coverImageSrc",
),
});
}
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if world_meta.world_subtitle.is_empty() {
None
} else {
Some(world_meta.world_subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
let current_scene_preset = game_state_object
.and_then(|state| state.get("currentScenePreset"))
.and_then(Value::as_object);
Some(RuntimeProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name: world_meta.world_title,
subtitle: world_meta.world_subtitle,
summary_text,
cover_image_src: current_scene_preset
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "imageSrc")),
})
}
#[cfg(any())]
pub fn read_runtime_json_non_negative_u64(value: Option<&Value>) -> u64 {
match value {
Some(Value::Number(number)) => {
if let Some(raw) = number.as_u64() {
raw
} else if let Some(raw) = number.as_i64() {
raw.max(0) as u64
} else if let Some(raw) = number.as_f64() {
if raw.is_finite() && raw > 0.0 {
raw.floor() as u64
} else {
0
}
} else {
0
}
}
Some(Value::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
_ => 0,
}
}
#[cfg(any())]
pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option<String> {
read_runtime_json_string_field_from_map(value.as_object()?, field)
}
#[cfg(any())]
pub fn read_runtime_json_string_field_from_map(
value: &serde_json::Map<String, Value>,
field: &str,
) -> Option<String> {
value
.get(field)?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(any())]
pub fn build_runtime_builtin_world_title(world_type: &str) -> String {
match world_type {
"WUXIA" => "武侠世界".to_string(),
"XIANXIA" => "仙侠世界".to_string(),
_ => "叙事世界".to_string(),
}
}
#[cfg(any())]
fn parse_optional_json_value(
raw: Option<&str>,
error: RuntimeProfileFieldError,
) -> Result<Option<Value>, RuntimeProfileFieldError> {
match raw.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => serde_json::from_str::<Value>(value)
.map(Some)
.map_err(|_| error),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_gate_denies_anonymous_when_enabled() {
let gate = test_gate("creation-entry:puzzle");
assert!(!is_feature_gate_allowed(
Some(&gate),
&FeatureGateUserContext::default(),
));
}
#[test]
fn feature_gate_deny_user_ids_override_allow_rules() {
let mut gate = test_gate("creation-entry:puzzle");
gate.allow_user_ids = vec!["user-1".to_string()];
gate.allow_user_tags = vec!["vip".to_string()];
gate.deny_user_ids = vec!["user-1".to_string()];
gate.rollout_percent = 100;
assert!(!is_feature_gate_allowed(
Some(&gate),
&FeatureGateUserContext {
user_id: Some("user-1".to_string()),
user_tags: vec!["vip".to_string()],
},
));
}
#[test]
fn feature_gate_allows_explicit_user_tag() {
let mut gate = test_gate("creation-entry:puzzle");
gate.allow_user_tags = vec!["vip".to_string()];
assert!(is_feature_gate_allowed(
Some(&gate),
&FeatureGateUserContext {
user_id: Some("user-2".to_string()),
user_tags: vec!["vip".to_string()],
},
));
}
#[cfg(any())]
#[test]
fn creation_entry_gate_sets_denied_entry_hidden_and_closed() {
let config = CreationEntryConfigResponse {
start_card: CreationEntryStartCardResponse {
title: String::new(),
description: String::new(),
idle_badge: String::new(),
busy_badge: String::new(),
},
type_modal: CreationEntryTypeModalResponse {
title: String::new(),
description: String::new(),
},
event_banner: CreationEntryEventBannerResponse {
title: String::new(),
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: "structured".to_string(),
html_code: None,
},
event_banners: vec![],
public_work_interactions: vec![],
creation_types: vec![CreationEntryTypeResponse {
id: "puzzle".to_string(),
title: "拼图".to_string(),
subtitle: String::new(),
badge: String::new(),
image_src: String::new(),
visible: true,
open: true,
sort_order: 1,
category_id: DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(),
category_label: DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string(),
category_sort_order: 0,
updated_at_micros: 1,
unified_creation_spec: None,
}],
};
let filtered = apply_feature_gates_to_creation_entry_config(
config,
&[test_gate("creation-entry:puzzle")],
&FeatureGateUserContext {
user_id: Some("user-3".to_string()),
user_tags: vec![],
},
);
assert!(!filtered.creation_types[0].visible);
assert!(!filtered.creation_types[0].open);
}
fn test_gate(gate_key: &str) -> FeatureGateConfigSnapshot {
FeatureGateConfigSnapshot {
gate_key: gate_key.to_string(),
enabled: true,
rollout_percent: 0,
allow_user_ids: vec![],
allow_user_tags: vec![],
deny_user_ids: vec![],
description: String::new(),
updated_at_micros: 1,
}
}
}