Files
Genarrative/server-rs/crates/module-runtime/src/application.rs
T
k88936 a48b7f8de8
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
Merge remote-tracking branch 'origin/master' into feat/pricing-plan
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-10-05 10:50:30 +08:00

2497 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 运行时应用编排。
//!
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 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::civil_calendar::{
civil_from_days, days_from_civil, days_in_month, is_leap_year, weekday_from_date_key,
};
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";
pub const AGC_TEMPLATE_LIBRARY_GATE_KEY: &str = "agc:template-library";
/// 游戏分发发布开关:gate 行缺失或 `enabled=false` 都表示**未开放**;
/// 只有 `enabled=true` 且白名单 / 灰度比例 / 用户标签命中时才允许发布。
pub const GAME_DISTRIBUTION_PUBLISH_GATE_KEY: &str = "game-distribution:publish";
#[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_plans: snapshot.membership_plans,
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,
}
}
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,
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_record(
snapshot: RuntimeProfileMembershipSnapshot,
) -> RuntimeProfileMembershipRecord {
RuntimeProfileMembershipRecord {
user_id: snapshot.user_id,
status: snapshot.status,
plan: snapshot.plan,
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_index: snapshot.cycle_index,
cycle_count: snapshot.cycle_count,
cycle_kind: snapshot.cycle_kind,
}
}
pub fn build_runtime_profile_membership_upgrade_quote_record(
snapshot: RuntimeProfileMembershipUpgradeQuoteSnapshot,
) -> RuntimeProfileMembershipUpgradeQuoteRecord {
RuntimeProfileMembershipUpgradeQuoteRecord {
current_plan: snapshot.current_plan,
target_plan: snapshot.target_plan,
cycle_kind: snapshot.cycle_kind,
cycle_index: snapshot.cycle_index,
cycle_count: snapshot.cycle_count,
amount_cents: snapshot.amount_cents,
granted_points_delta: snapshot.granted_points_delta,
monthly_balance_after: snapshot.monthly_balance_after,
expires_at: format_utc_micros(snapshot.expires_at_micros),
expires_at_micros: snapshot.expires_at_micros,
cycle_resets_at: format_utc_micros(snapshot.cycle_resets_at_micros),
cycle_resets_at_micros: snapshot.cycle_resets_at_micros,
remaining_full_months: snapshot.remaining_full_months,
}
}
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 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
}
}
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 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,
}
}
}