Files
Genarrative/server-rs/crates/module-runtime/src/application.rs
T

2138 lines
75 KiB
Rust

//! 运行时应用编排。
//!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
use serde_json::Value;
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use std::collections::BTreeMap;
use crate::domain::*;
use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, PublicWorkInteractionConfigResponse,
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));
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(),
}
}
/// 返回公开作品点赞 / 改造默认矩阵,保持历史前端硬编码能力不变。
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(
"jump-hop",
false,
false,
"作品类型 jump-hop 暂不支持点赞。",
"跳一跳作品改造将在后续版本开放。",
),
public_work_interaction_config(
"wooden-fish",
false,
false,
"作品类型 wooden-fish 暂不支持点赞。",
"敲木鱼作品改造将在后续版本开放。",
),
public_work_interaction_config(
"match3d",
false,
false,
"作品类型 match3d 暂不支持点赞。",
"抓大鹅作品改造将在后续版本开放。",
),
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,
"宝贝识物点赞将在后续版本开放。",
"宝贝识物作品改造将在创作链路接入后开放。",
),
]
}
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 表字段持久化。
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。
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 的默认项。
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())
}
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。
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 得到前台可消费的公开作品互动矩阵。
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()
}
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,
}
}
/// 返回平台默认公告配置,用于空库种子和旧库兜底。
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 表字段持久化。
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> {
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 响应字段。
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> {
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(
"match3d",
"抓大鹅",
"3D 消除关卡",
"可创建",
"/creation-type-references/match3d.webp",
true,
true,
40,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"jump-hop",
"跳一跳",
"主题驱动平台跳跃",
"可创建",
"/creation-type-references/jump-hop.webp",
true,
true,
45,
"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)]
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),
}
}
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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
})
}
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,
})
}
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,
})
}
pub fn build_runtime_profile_played_world_id(user_id: &str, world_key: &str) -> String {
format!("{}:{}", user_id.trim(), world_key.trim())
}
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
)
}
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 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,
) -> Result<(), RuntimeProfileFieldError> {
if !code.enabled {
return Err(RuntimeProfileFieldError::RedeemCodeDisabled);
}
if code.reward_points == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
}
match code.mode {
RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Unique if code.global_used_count >= code.max_uses => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Private => {
if !code.allowed_user_ids.iter().any(|item| item == user_id) {
return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser);
}
if code.global_used_count >= code.max_uses {
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)
}
}
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
}
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")
)
}
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(),
})
}
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")),
})
}
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,
}
}
pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option<String> {
read_runtime_json_string_field_from_map(value.as_object()?, field)
}
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)
}
pub fn build_runtime_builtin_world_title(world_type: &str) -> String {
match world_type {
"WUXIA" => "武侠世界".to_string(),
"XIANXIA" => "仙侠世界".to_string(),
_ => "叙事世界".to_string(),
}
}
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),
}
}