Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -1,3 +1,472 @@
//! 运行时写入命令过渡落位。
//!
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
use std::collections::HashSet;
use serde_json::Value;
use shared_kernel::{
normalize_optional_string, normalize_required_string, parse_rfc3339 as parse_shared_rfc3339,
};
use crate::domain::*;
use crate::errors::*;
use crate::{format_utc_micros, runtime_profile_recharge_product_by_id};
// 统一把共享必填字符串归一化映射到 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_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_wallet_adjustment_input(
user_id: String,
amount: u64,
ledger_id: String,
created_at_micros: i64,
) -> Result<RuntimeProfileWalletAdjustmentInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let ledger_id =
normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?;
if amount == 0 || amount > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidWalletAmount);
}
Ok(RuntimeProfileWalletAdjustmentInput {
user_id,
amount,
ledger_id,
created_at_micros,
})
}
pub fn build_runtime_profile_recharge_center_get_input(
user_id: String,
) -> Result<RuntimeProfileRechargeCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileRechargeCenterGetInput { user_id })
}
pub fn build_runtime_profile_recharge_order_create_input(
user_id: String,
product_id: String,
payment_channel: String,
created_at_micros: i64,
) -> Result<RuntimeProfileRechargeOrderCreateInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let product_id =
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
if runtime_profile_recharge_product_by_id(&product_id).is_none() {
return Err(RuntimeProfileFieldError::UnknownRechargeProduct);
}
let payment_channel = normalize_required_string(payment_channel)
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
Ok(RuntimeProfileRechargeOrderCreateInput {
user_id,
product_id,
payment_channel,
created_at_micros,
})
}
pub fn build_runtime_referral_invite_center_get_input(
user_id: String,
) -> Result<RuntimeReferralInviteCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeReferralInviteCenterGetInput { user_id })
}
pub fn build_runtime_referral_redeem_input(
user_id: String,
invite_code: String,
updated_at_micros: i64,
) -> Result<RuntimeReferralRedeemInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
Ok(RuntimeReferralRedeemInput {
user_id,
invite_code,
updated_at_micros,
})
}
pub fn build_runtime_profile_reward_code_redeem_input(
user_id: String,
code: String,
redeemed_at_micros: i64,
) -> Result<RuntimeProfileRewardCodeRedeemInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
Ok(RuntimeProfileRewardCodeRedeemInput {
user_id,
code,
redeemed_at_micros,
})
}
pub fn build_runtime_profile_redeem_code_admin_upsert_input(
admin_user_id: String,
code: String,
mode: RuntimeProfileRedeemCodeMode,
reward_points: u64,
max_uses: u32,
enabled: bool,
allowed_user_ids: Vec<String>,
allowed_public_user_codes: Vec<String>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
if reward_points == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
}
if max_uses == 0 {
return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses);
}
Ok(RuntimeProfileRedeemCodeAdminUpsertInput {
admin_user_id,
code,
mode,
reward_points,
max_uses,
enabled,
allowed_user_ids: allowed_user_ids
.into_iter()
.filter_map(|value| normalize_optional_string(Some(value)))
.collect(),
allowed_public_user_codes: allowed_public_user_codes
.into_iter()
.filter_map(|value| normalize_optional_string(Some(value)))
.collect(),
updated_at_micros,
})
}
pub fn build_runtime_profile_redeem_code_admin_disable_input(
admin_user_id: String,
code: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminDisableInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?;
Ok(RuntimeProfileRedeemCodeAdminDisableInput {
admin_user_id,
code,
updated_at_micros,
})
}
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_snapshot_get_input(
user_id: String,
) -> Result<RuntimeSnapshotGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeSnapshotGetInput { user_id })
}
pub fn build_runtime_snapshot_delete_input(
user_id: String,
) -> Result<RuntimeSnapshotDeleteInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeSnapshotDeleteInput { user_id })
}
pub fn build_runtime_profile_save_archive_list_input(
user_id: String,
) -> Result<RuntimeProfileSaveArchiveListInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileSaveArchiveListInput { user_id })
}
pub fn build_runtime_profile_save_archive_resume_input(
user_id: String,
world_key: String,
) -> Result<RuntimeProfileSaveArchiveResumeInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let world_key =
normalize_required_string(world_key).ok_or(RuntimeProfileFieldError::MissingWorldKey)?;
Ok(RuntimeProfileSaveArchiveResumeInput { user_id, world_key })
}
pub fn build_runtime_save_checkpoint_input(
session_id: String,
bottom_tab: String,
saved_at_micros: i64,
updated_at_micros: i64,
) -> Result<RuntimeSaveCheckpointInput, RuntimeProfileFieldError> {
let session_id = normalize_required_string(session_id)
.ok_or(RuntimeProfileFieldError::MissingCheckpointSessionId)?;
let bottom_tab =
normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
Ok(RuntimeSaveCheckpointInput {
session_id,
bottom_tab,
saved_at_micros,
updated_at_micros,
})
}
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_snapshot_upsert_input(
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> Result<RuntimeSnapshotUpsertInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let bottom_tab =
normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
let game_state_json = serde_json::to_string(&game_state)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story_json = normalize_current_story_json(current_story)?;
Ok(RuntimeSnapshotUpsertInput {
user_id,
saved_at_micros,
bottom_tab,
game_state_json,
current_story_json,
updated_at_micros,
})
}
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_id(
user_id: &str,
owner_user_id: &str,
profile_id: &str,
) -> String {
format!("{user_id}:{owner_user_id}:{profile_id}")
}
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()
}
fn normalize_bottom_tab(value: String) -> Option<String> {
let trimmed = normalize_required_string(value)?;
let normalized = match trimmed.as_str() {
"character" | "inventory" => trimmed,
_ => "adventure".to_string(),
};
Some(normalized)
}
fn normalize_current_story_json(
current_story: Option<Value>,
) -> Result<Option<String>, RuntimeProfileFieldError> {
let Some(current_story) = current_story else {
return Ok(None);
};
if !current_story.is_object() {
return Ok(None);
}
serde_json::to_string(&current_story)
.map(Some)
.map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson)
}
pub fn normalize_invite_code(value: String) -> Option<String> {
let normalized = value
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_uppercase())
.collect::<String>();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
pub fn normalize_redeem_code(value: String) -> Option<String> {
normalize_invite_code(value)
}