后端重写提交
This commit is contained in:
980
server-rs/crates/module-runtime/src/lib.rs
Normal file
980
server-rs/crates/module-runtime/src/lib.rs
Normal file
@@ -0,0 +1,980 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{
|
||||
format_rfc3339 as format_shared_rfc3339, normalize_optional_string, normalize_required_string,
|
||||
parse_rfc3339 as parse_shared_rfc3339,
|
||||
};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
pub const DEFAULT_MUSIC_VOLUME: f32 = 0.42;
|
||||
pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::Light;
|
||||
pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家";
|
||||
pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
|
||||
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
|
||||
|
||||
// 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimePlatformTheme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
}
|
||||
|
||||
// 浏览历史沿用平台已有的六种世界主题,但独立冻结在 runtime 领域内,避免反向耦合创作域 crate。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeBrowseHistoryThemeMode {
|
||||
Martial,
|
||||
Arcane,
|
||||
Machina,
|
||||
Tide,
|
||||
Rift,
|
||||
Mythic,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingSnapshot {
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeSettingSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettingUpsertInput {
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistorySnapshot {
|
||||
pub browse_history_id: String,
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: RuntimeBrowseHistoryThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryProcedureResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<RuntimeBrowseHistorySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryListInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryClearInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistoryWriteInput {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: Option<String>,
|
||||
pub summary_text: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: Option<String>,
|
||||
pub author_display_name: Option<String>,
|
||||
pub visited_at: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeBrowseHistorySyncInput {
|
||||
pub user_id: String,
|
||||
pub entries: Vec<RuntimeBrowseHistoryWriteInput>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileDashboardSnapshot {
|
||||
pub user_id: String,
|
||||
pub wallet_balance: u64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_world_count: u32,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileDashboardProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileDashboardSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileDashboardGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
SnapshotSync,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
|
||||
pub wallet_ledger_id: String,
|
||||
pub user_id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: u64,
|
||||
pub source_type: RuntimeProfileWalletLedgerSourceType,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerProcedureResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<RuntimeProfileWalletLedgerEntrySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletLedgerListInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayedWorldSnapshot {
|
||||
pub played_world_id: String,
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at_micros: i64,
|
||||
pub last_played_at_micros: i64,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayStatsSnapshot {
|
||||
pub user_id: String,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_works: Vec<RuntimeProfilePlayedWorldSnapshot>,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayStatsProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfilePlayStatsSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfilePlayStatsGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeSettingsFieldError {
|
||||
MissingUserId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeBrowseHistoryFieldError {
|
||||
MissingUserId,
|
||||
TooManyEntries,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeProfileFieldError {
|
||||
MissingUserId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeBrowseHistoryPreparedEntry {
|
||||
pub browse_history_id: String,
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: RuntimeBrowseHistoryThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeBrowseHistoryRecord {
|
||||
pub browse_history_id: String,
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: RuntimeBrowseHistoryThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at: String,
|
||||
pub visited_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl RuntimePlatformTheme {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Light => "light",
|
||||
Self::Dark => "dark",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_client_str(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("dark") {
|
||||
Self::Dark
|
||||
} else {
|
||||
Self::Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeBrowseHistoryThemeMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Martial => "martial",
|
||||
Self::Arcane => "arcane",
|
||||
Self::Machina => "machina",
|
||||
Self::Tide => "tide",
|
||||
Self::Rift => "rift",
|
||||
Self::Mythic => "mythic",
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览历史主题沿用旧 Node 逻辑:不做严格校验,未知值统一回退到 mythic。
|
||||
pub fn from_client_str(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"martial" => Self::Martial,
|
||||
"arcane" => Self::Arcane,
|
||||
"machina" => Self::Machina,
|
||||
"tide" => Self::Tide,
|
||||
"rift" => Self::Rift,
|
||||
_ => Self::Mythic,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeSettings {
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
music_volume: DEFAULT_MUSIC_VOLUME,
|
||||
platform_theme: DEFAULT_PLATFORM_THEME,
|
||||
}
|
||||
}
|
||||
|
||||
// 与旧 Node 仓储保持一致:音量 clamp 到 0~1,主题除 dark 外统一回退到 light。
|
||||
pub fn normalized(music_volume: f32, platform_theme: RuntimePlatformTheme) -> Self {
|
||||
Self {
|
||||
music_volume: music_volume.clamp(0.0, 1.0),
|
||||
platform_theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。
|
||||
fn normalize_runtime_settings_user_id(
|
||||
user_id: String,
|
||||
) -> Result<String, RuntimeSettingsFieldError> {
|
||||
normalize_required_string(user_id).ok_or(RuntimeSettingsFieldError::MissingUserId)
|
||||
}
|
||||
|
||||
fn normalize_runtime_browse_history_user_id(
|
||||
user_id: String,
|
||||
) -> Result<String, RuntimeBrowseHistoryFieldError> {
|
||||
normalize_required_string(user_id).ok_or(RuntimeBrowseHistoryFieldError::MissingUserId)
|
||||
}
|
||||
|
||||
fn normalize_runtime_profile_user_id(user_id: String) -> Result<String, RuntimeProfileFieldError> {
|
||||
normalize_required_string(user_id).ok_or(RuntimeProfileFieldError::MissingUserId)
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeSettingGetInput, RuntimeSettingsFieldError> {
|
||||
let user_id = normalize_runtime_settings_user_id(user_id)?;
|
||||
Ok(RuntimeSettingGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_setting_upsert_input(
|
||||
user_id: String,
|
||||
music_volume: f32,
|
||||
platform_theme: RuntimePlatformTheme,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeSettingUpsertInput, RuntimeSettingsFieldError> {
|
||||
let user_id = normalize_runtime_settings_user_id(user_id)?;
|
||||
let normalized = RuntimeSettings::normalized(music_volume, platform_theme);
|
||||
|
||||
Ok(RuntimeSettingUpsertInput {
|
||||
user_id,
|
||||
music_volume: normalized.music_volume,
|
||||
platform_theme: normalized.platform_theme,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeSettingsRecord {
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileDashboardRecord {
|
||||
pub user_id: String,
|
||||
pub wallet_balance: u64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_world_count: u32,
|
||||
pub updated_at: Option<String>,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileWalletLedgerEntryRecord {
|
||||
pub wallet_ledger_id: String,
|
||||
pub user_id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: u64,
|
||||
pub source_type: RuntimeProfileWalletLedgerSourceType,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfilePlayedWorldRecord {
|
||||
pub played_world_id: String,
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at: String,
|
||||
pub first_played_at_micros: i64,
|
||||
pub last_played_at: String,
|
||||
pub last_played_at_micros: i64,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfilePlayStatsRecord {
|
||||
pub user_id: String,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_works: Vec<RuntimeProfilePlayedWorldRecord>,
|
||||
pub updated_at: Option<String>,
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_list_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryFieldError> {
|
||||
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
|
||||
Ok(RuntimeBrowseHistoryListInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_dashboard_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfileDashboardGetInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfileDashboardGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_ledger_list_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfileWalletLedgerListInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfileWalletLedgerListInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfilePlayStatsGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_clear_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryFieldError> {
|
||||
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
|
||||
Ok(RuntimeBrowseHistoryClearInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_sync_input(
|
||||
user_id: String,
|
||||
entries: Vec<RuntimeBrowseHistoryWriteInput>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeBrowseHistorySyncInput, RuntimeBrowseHistoryFieldError> {
|
||||
let user_id = normalize_runtime_browse_history_user_id(user_id)?;
|
||||
if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE {
|
||||
return Err(RuntimeBrowseHistoryFieldError::TooManyEntries);
|
||||
}
|
||||
|
||||
let mut normalized_entries = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let Some(owner_user_id) = normalize_required_string(entry.owner_user_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(profile_id) = normalize_required_string(entry.profile_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(world_name) = normalize_required_string(entry.world_name) else {
|
||||
continue;
|
||||
};
|
||||
// 与旧 Node 仓储保持一致:单条缺少关键字段时静默过滤,不让整批请求失败。
|
||||
let visited_at_micros = entry
|
||||
.visited_at
|
||||
.as_deref()
|
||||
.and_then(parse_utc_rfc3339_to_micros)
|
||||
.unwrap_or(updated_at_micros);
|
||||
|
||||
normalized_entries.push(RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_name,
|
||||
subtitle: normalize_optional_string(entry.subtitle),
|
||||
summary_text: normalize_optional_string(entry.summary_text),
|
||||
cover_image_src: normalize_optional_string(entry.cover_image_src),
|
||||
theme_mode: normalize_optional_string(entry.theme_mode),
|
||||
author_display_name: normalize_optional_string(entry.author_display_name),
|
||||
// 统一把 visitedAt 收口成 RFC3339,避免后续排序与回包格式继续漂移。
|
||||
visited_at: Some(format_utc_micros(visited_at_micros)),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RuntimeBrowseHistorySyncInput {
|
||||
user_id,
|
||||
entries: normalized_entries,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare_runtime_browse_history_entries(
|
||||
input: RuntimeBrowseHistorySyncInput,
|
||||
) -> Result<Vec<RuntimeBrowseHistoryPreparedEntry>, RuntimeBrowseHistoryFieldError> {
|
||||
let validated_input = build_runtime_browse_history_sync_input(
|
||||
input.user_id,
|
||||
input.entries,
|
||||
input.updated_at_micros,
|
||||
)?;
|
||||
let mut prepared_entries = validated_input
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let visited_at_micros = entry
|
||||
.visited_at
|
||||
.as_deref()
|
||||
.and_then(parse_utc_rfc3339_to_micros)
|
||||
.unwrap_or(validated_input.updated_at_micros);
|
||||
|
||||
RuntimeBrowseHistoryPreparedEntry {
|
||||
browse_history_id: build_runtime_browse_history_id(
|
||||
&validated_input.user_id,
|
||||
&entry.owner_user_id,
|
||||
&entry.profile_id,
|
||||
),
|
||||
user_id: validated_input.user_id.clone(),
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
world_name: entry.world_name,
|
||||
subtitle: entry.subtitle.unwrap_or_default(),
|
||||
summary_text: entry.summary_text.unwrap_or_default(),
|
||||
cover_image_src: entry.cover_image_src,
|
||||
theme_mode: RuntimeBrowseHistoryThemeMode::from_client_str(
|
||||
entry.theme_mode.as_deref().unwrap_or("mythic"),
|
||||
),
|
||||
author_display_name: entry
|
||||
.author_display_name
|
||||
.unwrap_or_else(|| DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME.to_string()),
|
||||
visited_at_micros,
|
||||
updated_at_micros: validated_input.updated_at_micros,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 与旧 Node 仓储保持一致:先按 visitedAt 倒序,再按 owner/profile 去重,只保留最近一次访问。
|
||||
prepared_entries.sort_by(|left, right| {
|
||||
right
|
||||
.visited_at_micros
|
||||
.cmp(&left.visited_at_micros)
|
||||
.then_with(|| left.browse_history_id.cmp(&right.browse_history_id))
|
||||
});
|
||||
|
||||
let mut seen_ids = HashSet::new();
|
||||
prepared_entries.retain(|entry| seen_ids.insert(entry.browse_history_id.clone()));
|
||||
|
||||
Ok(prepared_entries)
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_record(
|
||||
snapshot: RuntimeBrowseHistorySnapshot,
|
||||
) -> RuntimeBrowseHistoryRecord {
|
||||
RuntimeBrowseHistoryRecord {
|
||||
browse_history_id: snapshot.browse_history_id,
|
||||
user_id: snapshot.user_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
theme_mode: snapshot.theme_mode,
|
||||
author_display_name: snapshot.author_display_name,
|
||||
visited_at: format_utc_micros(snapshot.visited_at_micros),
|
||||
visited_at_micros: snapshot.visited_at_micros,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_dashboard_record(
|
||||
snapshot: RuntimeProfileDashboardSnapshot,
|
||||
) -> RuntimeProfileDashboardRecord {
|
||||
RuntimeProfileDashboardRecord {
|
||||
user_id: snapshot.user_id,
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_world_count: snapshot.played_world_count,
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_ledger_entry_record(
|
||||
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
) -> RuntimeProfileWalletLedgerEntryRecord {
|
||||
RuntimeProfileWalletLedgerEntryRecord {
|
||||
wallet_ledger_id: snapshot.wallet_ledger_id,
|
||||
user_id: snapshot.user_id,
|
||||
amount_delta: snapshot.amount_delta,
|
||||
balance_after: snapshot.balance_after,
|
||||
source_type: snapshot.source_type,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
RuntimeProfilePlayedWorldRecord {
|
||||
played_world_id: snapshot.played_world_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_title: snapshot.world_title,
|
||||
world_subtitle: snapshot.world_subtitle,
|
||||
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
|
||||
first_played_at_micros: snapshot.first_played_at_micros,
|
||||
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
|
||||
last_played_at_micros: snapshot.last_played_at_micros,
|
||||
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_record(
|
||||
snapshot: RuntimeProfilePlayStatsSnapshot,
|
||||
) -> RuntimeProfilePlayStatsRecord {
|
||||
RuntimeProfilePlayStatsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_works: snapshot
|
||||
.played_works
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_played_world_record)
|
||||
.collect(),
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_id(
|
||||
user_id: &str,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
) -> String {
|
||||
format!("{user_id}:{owner_user_id}:{profile_id}")
|
||||
}
|
||||
|
||||
pub fn format_utc_micros(micros: i64) -> String {
|
||||
let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
|
||||
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
format_shared_rfc3339(timestamp).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
fn parse_utc_rfc3339_to_micros(value: &str) -> Option<i64> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let nanos = parse_shared_rfc3339(trimmed).ok()?.unix_timestamp_nanos();
|
||||
i64::try_from(nanos / 1_000).ok()
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeSettingsFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("runtime_setting.user_id 不能为空"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeBrowseHistoryFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("browse_history.user_id 不能为空"),
|
||||
Self::TooManyEntries => write!(
|
||||
f,
|
||||
"browse_history.entries 单次最多只允许 {} 条",
|
||||
MAX_BROWSE_HISTORY_BATCH_SIZE
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeProfileWalletLedgerSourceType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SnapshotSync => "snapshot_sync",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_shared_contract_baseline() {
|
||||
let settings = RuntimeSettings::defaults();
|
||||
|
||||
assert!((settings.music_volume - DEFAULT_MUSIC_VOLUME).abs() < f32::EPSILON);
|
||||
assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_music_volume_into_valid_range() {
|
||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||
let high = RuntimeSettings::normalized(3.5, RuntimePlatformTheme::Dark);
|
||||
|
||||
assert_eq!(low.music_volume, 0.0);
|
||||
assert_eq!(high.music_volume, 1.0);
|
||||
assert_eq!(high.platform_theme, RuntimePlatformTheme::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_from_client_string_falls_back_to_light() {
|
||||
assert_eq!(
|
||||
RuntimePlatformTheme::from_client_str("dark"),
|
||||
RuntimePlatformTheme::Dark
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimePlatformTheme::from_client_str("LIGHT"),
|
||||
RuntimePlatformTheme::Light
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimePlatformTheme::from_client_str("mythic"),
|
||||
RuntimePlatformTheme::Light
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upsert_input_rejects_blank_user_id() {
|
||||
let error = build_runtime_setting_upsert_input(
|
||||
" ".to_string(),
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
RuntimePlatformTheme::Light,
|
||||
1,
|
||||
)
|
||||
.expect_err("blank user id should fail");
|
||||
|
||||
assert_eq!(error, RuntimeSettingsFieldError::MissingUserId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_history_theme_from_client_string_falls_back_to_mythic() {
|
||||
assert_eq!(
|
||||
RuntimeBrowseHistoryThemeMode::from_client_str("martial"),
|
||||
RuntimeBrowseHistoryThemeMode::Martial
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeBrowseHistoryThemeMode::from_client_str("RIFT"),
|
||||
RuntimeBrowseHistoryThemeMode::Rift
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeBrowseHistoryThemeMode::from_client_str("unknown"),
|
||||
RuntimeBrowseHistoryThemeMode::Mythic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_browse_history_sync_input_normalizes_optionals_and_visited_at() {
|
||||
let input = build_runtime_browse_history_sync_input(
|
||||
" user-1 ".to_string(),
|
||||
vec![RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: " owner-a ".to_string(),
|
||||
profile_id: " profile-a ".to_string(),
|
||||
world_name: " 世界A ".to_string(),
|
||||
subtitle: Some(" ".to_string()),
|
||||
summary_text: Some(" 简介 ".to_string()),
|
||||
cover_image_src: Some(" /cover.png ".to_string()),
|
||||
theme_mode: Some(" arcane ".to_string()),
|
||||
author_display_name: Some(" ".to_string()),
|
||||
visited_at: None,
|
||||
}],
|
||||
1_713_680_000_000_000,
|
||||
)
|
||||
.expect("sync input should build");
|
||||
|
||||
assert_eq!(input.user_id, "user-1");
|
||||
assert_eq!(input.entries.len(), 1);
|
||||
assert_eq!(input.entries[0].owner_user_id, "owner-a");
|
||||
assert_eq!(input.entries[0].profile_id, "profile-a");
|
||||
assert_eq!(input.entries[0].world_name, "世界A");
|
||||
assert_eq!(input.entries[0].subtitle, None);
|
||||
assert_eq!(input.entries[0].summary_text, Some("简介".to_string()));
|
||||
assert_eq!(
|
||||
input.entries[0].cover_image_src,
|
||||
Some("/cover.png".to_string())
|
||||
);
|
||||
assert_eq!(input.entries[0].theme_mode, Some("arcane".to_string()));
|
||||
assert_eq!(input.entries[0].author_display_name, None);
|
||||
assert_eq!(
|
||||
input.entries[0].visited_at,
|
||||
Some("2024-04-21T06:13:20Z".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_browse_history_entries_sorts_desc_and_dedups_by_owner_profile() {
|
||||
let entries = prepare_runtime_browse_history_entries(RuntimeBrowseHistorySyncInput {
|
||||
user_id: "user-1".to_string(),
|
||||
entries: vec![
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
world_name: "世界旧".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: Some("martial".to_string()),
|
||||
author_display_name: None,
|
||||
visited_at: Some("2026-04-20T10:00:00Z".to_string()),
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-b".to_string(),
|
||||
profile_id: "profile-b".to_string(),
|
||||
world_name: "世界B".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: Some("rift".to_string()),
|
||||
author_display_name: Some("作者B".to_string()),
|
||||
visited_at: Some("2026-04-21T10:00:00Z".to_string()),
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-a".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
world_name: "世界新".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: Some("unknown".to_string()),
|
||||
author_display_name: Some("".to_string()),
|
||||
visited_at: Some("2026-04-21T11:00:00Z".to_string()),
|
||||
},
|
||||
],
|
||||
updated_at_micros: 1_776_000_000_000_000,
|
||||
})
|
||||
.expect("entries should prepare");
|
||||
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].world_name, "世界新");
|
||||
assert_eq!(entries[0].theme_mode, RuntimeBrowseHistoryThemeMode::Mythic);
|
||||
assert_eq!(
|
||||
entries[0].author_display_name,
|
||||
DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME
|
||||
);
|
||||
assert_eq!(entries[1].world_name, "世界B");
|
||||
assert!(entries[0].visited_at_micros > entries[1].visited_at_micros);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_browse_history_sync_input_silently_filters_invalid_entries() {
|
||||
let input = build_runtime_browse_history_sync_input(
|
||||
"user-1".to_string(),
|
||||
vec![
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: " ".to_string(),
|
||||
profile_id: "profile-a".to_string(),
|
||||
world_name: "世界A".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: None,
|
||||
author_display_name: None,
|
||||
visited_at: None,
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-b".to_string(),
|
||||
profile_id: "profile-b".to_string(),
|
||||
world_name: " 世界B ".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: None,
|
||||
author_display_name: None,
|
||||
visited_at: None,
|
||||
},
|
||||
RuntimeBrowseHistoryWriteInput {
|
||||
owner_user_id: "owner-c".to_string(),
|
||||
profile_id: "".to_string(),
|
||||
world_name: "世界C".to_string(),
|
||||
subtitle: None,
|
||||
summary_text: None,
|
||||
cover_image_src: None,
|
||||
theme_mode: None,
|
||||
author_display_name: None,
|
||||
visited_at: None,
|
||||
},
|
||||
],
|
||||
1_776_000_000_000_000,
|
||||
)
|
||||
.expect("sync input should build");
|
||||
|
||||
assert_eq!(input.entries.len(), 1);
|
||||
assert_eq!(input.entries[0].owner_user_id, "owner-b");
|
||||
assert_eq!(input.entries[0].profile_id, "profile-b");
|
||||
assert_eq!(input.entries[0].world_name, "世界B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_inputs_reject_blank_user_id() {
|
||||
assert_eq!(
|
||||
build_runtime_profile_dashboard_get_input(" ".to_string())
|
||||
.expect_err("dashboard input should fail"),
|
||||
RuntimeProfileFieldError::MissingUserId
|
||||
);
|
||||
assert_eq!(
|
||||
build_runtime_profile_wallet_ledger_list_input(" ".to_string())
|
||||
.expect_err("wallet ledger input should fail"),
|
||||
RuntimeProfileFieldError::MissingUserId
|
||||
);
|
||||
assert_eq!(
|
||||
build_runtime_profile_play_stats_get_input(" ".to_string())
|
||||
.expect_err("play stats input should fail"),
|
||||
RuntimeProfileFieldError::MissingUserId
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dashboard_record_formats_optional_timestamp() {
|
||||
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
wallet_balance: 8,
|
||||
total_play_time_ms: 12,
|
||||
played_world_count: 2,
|
||||
updated_at_micros: Some(1_713_680_000_000_000),
|
||||
});
|
||||
|
||||
assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_wallet_ledger_source_type_formats_to_snapshot_sync() {
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
|
||||
"snapshot_sync"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user