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