Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前提交仅完成目录占位,不提前进入表结构、projection 与接口实现。
|
||||
当前已进入 DDD 分层拆分阶段,但仍以小切片推进,不提前改动未冻结的表结构、projection 与接口实现。
|
||||
|
||||
后续与本 package 直接相关的任务包括:
|
||||
|
||||
@@ -23,6 +23,22 @@
|
||||
3. 设计 `profile_played_world`、`profile_save_archive`、`user_browse_history`
|
||||
4. 落地存档、设置、资料页兼容接口
|
||||
|
||||
已落地的拆分切片:
|
||||
|
||||
1. `runtime settings` 的默认值、平台主题值对象与设置聚合已迁入 `src/domain.rs`,根入口通过 `pub use domain::*` 保持原有 crate API。
|
||||
2. runtime snapshot、profile dashboard、wallet、recharge、referral、played world、play stats、save archive 的快照、输入、过程结果与记录投影类型已迁入 `src/domain.rs`。
|
||||
3. settings、browse history、profile/save 三组字段错误和中文错误文案已迁入 `src/errors.rs`。
|
||||
4. settings、browse history、profile/save 等输入构造和写入归一化函数已迁入 `src/commands.rs`。
|
||||
5. settings、browse history、profile/save 等记录投影 builder 已迁入 `src/application.rs`。
|
||||
6. checkpoint、profile/save archive meta、充值/邀请/兑换/钱包等剩余纯规则已迁入 `src/application.rs`,`spacetime-module` 只保留表事务读写,`api-server` 只保留 HTTP/BFF 映射。
|
||||
7. 详细边界与验收记录见:
|
||||
- `docs/technical/SERVER_RS_DDD_WP_RT_RUNTIME_SETTINGS_DOMAIN_REFACTOR_2026-04-29.md`
|
||||
- `docs/technical/SERVER_RS_DDD_WP_RT_DOMAIN_SNAPSHOT_RECORD_REFACTOR_2026-04-29.md`
|
||||
- `docs/technical/SERVER_RS_DDD_WP_RT_ERROR_LAYER_REFACTOR_2026-04-29.md`
|
||||
- `docs/technical/SERVER_RS_DDD_WP_RT_COMMANDS_REFACTOR_2026-04-29.md`
|
||||
- `docs/technical/SERVER_RS_DDD_WP_RT_APPLICATION_RECORD_REFACTOR_2026-04-29.md`
|
||||
- `docs/technical/SERVER_RS_DDD_WP_RT_ADAPTER_API_CLOSURE_2026-04-29.md`
|
||||
|
||||
## 3. 边界约束
|
||||
|
||||
1. `module-runtime` 负责运行时状态真相与模块级 facade 编排,不把主状态继续留在旧式大 JSON repository 中。
|
||||
|
||||
@@ -1,3 +1,798 @@
|
||||
//! 运行时应用编排过渡落位。
|
||||
//!
|
||||
//! 这里只返回运行时快照、个人页投影和领域事件,不直接访问外部 adapter。
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
|
||||
use crate::domain::*;
|
||||
use crate::errors::RuntimeProfileFieldError;
|
||||
use crate::format_utc_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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_record(
|
||||
snapshot: RuntimeBrowseHistorySnapshot,
|
||||
) -> RuntimeBrowseHistoryRecord {
|
||||
RuntimeBrowseHistoryRecord {
|
||||
browse_history_id: snapshot.browse_history_id,
|
||||
user_id: snapshot.user_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
theme_mode: snapshot.theme_mode,
|
||||
author_display_name: snapshot.author_display_name,
|
||||
visited_at: format_utc_micros(snapshot.visited_at_micros),
|
||||
visited_at_micros: snapshot.visited_at_micros,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_dashboard_record(
|
||||
snapshot: RuntimeProfileDashboardSnapshot,
|
||||
) -> RuntimeProfileDashboardRecord {
|
||||
RuntimeProfileDashboardRecord {
|
||||
user_id: snapshot.user_id,
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_world_count: snapshot.played_world_count,
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_ledger_entry_record(
|
||||
snapshot: RuntimeProfileWalletLedgerEntrySnapshot,
|
||||
) -> RuntimeProfileWalletLedgerEntryRecord {
|
||||
RuntimeProfileWalletLedgerEntryRecord {
|
||||
wallet_ledger_id: snapshot.wallet_ledger_id,
|
||||
user_id: snapshot.user_id,
|
||||
amount_delta: snapshot.amount_delta,
|
||||
balance_after: snapshot.balance_after,
|
||||
source_type: snapshot.source_type,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_center_record(
|
||||
snapshot: RuntimeProfileRechargeCenterSnapshot,
|
||||
) -> RuntimeProfileRechargeCenterRecord {
|
||||
RuntimeProfileRechargeCenterRecord {
|
||||
user_id: snapshot.user_id,
|
||||
wallet_balance: snapshot.wallet_balance,
|
||||
membership: build_runtime_profile_membership_record(snapshot.membership),
|
||||
point_products: snapshot
|
||||
.point_products
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_recharge_product_record)
|
||||
.collect(),
|
||||
membership_products: snapshot
|
||||
.membership_products
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_recharge_product_record)
|
||||
.collect(),
|
||||
benefits: snapshot
|
||||
.benefits
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_membership_benefit_record)
|
||||
.collect(),
|
||||
latest_order: snapshot
|
||||
.latest_order
|
||||
.map(build_runtime_profile_recharge_order_record),
|
||||
has_points_recharged: snapshot.has_points_recharged,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_product_record(
|
||||
snapshot: RuntimeProfileRechargeProductSnapshot,
|
||||
) -> RuntimeProfileRechargeProductRecord {
|
||||
RuntimeProfileRechargeProductRecord {
|
||||
product_id: snapshot.product_id,
|
||||
title: snapshot.title,
|
||||
price_cents: snapshot.price_cents,
|
||||
kind: snapshot.kind,
|
||||
points_amount: snapshot.points_amount,
|
||||
bonus_points: snapshot.bonus_points,
|
||||
duration_days: snapshot.duration_days,
|
||||
badge_label: snapshot.badge_label,
|
||||
description: snapshot.description,
|
||||
tier: snapshot.tier,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_membership_benefit_record(
|
||||
snapshot: RuntimeProfileMembershipBenefitSnapshot,
|
||||
) -> RuntimeProfileMembershipBenefitRecord {
|
||||
RuntimeProfileMembershipBenefitRecord {
|
||||
benefit_name: snapshot.benefit_name,
|
||||
normal_value: snapshot.normal_value,
|
||||
month_value: snapshot.month_value,
|
||||
season_value: snapshot.season_value,
|
||||
year_value: snapshot.year_value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_membership_record(
|
||||
snapshot: RuntimeProfileMembershipSnapshot,
|
||||
) -> RuntimeProfileMembershipRecord {
|
||||
RuntimeProfileMembershipRecord {
|
||||
user_id: snapshot.user_id,
|
||||
status: snapshot.status,
|
||||
tier: snapshot.tier,
|
||||
started_at: snapshot.started_at_micros.map(format_utc_micros),
|
||||
started_at_micros: snapshot.started_at_micros,
|
||||
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
|
||||
expires_at_micros: snapshot.expires_at_micros,
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_record(
|
||||
snapshot: RuntimeProfileRechargeOrderSnapshot,
|
||||
) -> RuntimeProfileRechargeOrderRecord {
|
||||
RuntimeProfileRechargeOrderRecord {
|
||||
order_id: snapshot.order_id,
|
||||
user_id: snapshot.user_id,
|
||||
product_id: snapshot.product_id,
|
||||
product_title: snapshot.product_title,
|
||||
kind: snapshot.kind,
|
||||
amount_cents: snapshot.amount_cents,
|
||||
status: snapshot.status,
|
||||
payment_channel: snapshot.payment_channel,
|
||||
paid_at: format_utc_micros(snapshot.paid_at_micros),
|
||||
paid_at_micros: snapshot.paid_at_micros,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
points_delta: snapshot.points_delta,
|
||||
membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros),
|
||||
membership_expires_at_micros: snapshot.membership_expires_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_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,
|
||||
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 build_runtime_profile_redeem_code_record(
|
||||
snapshot: RuntimeProfileRedeemCodeSnapshot,
|
||||
) -> RuntimeProfileRedeemCodeRecord {
|
||||
RuntimeProfileRedeemCodeRecord {
|
||||
code: snapshot.code,
|
||||
mode: snapshot.mode,
|
||||
reward_points: snapshot.reward_points,
|
||||
max_uses: snapshot.max_uses,
|
||||
global_used_count: snapshot.global_used_count,
|
||||
enabled: snapshot.enabled,
|
||||
allowed_user_ids: snapshot.allowed_user_ids,
|
||||
created_by: snapshot.created_by,
|
||||
created_at: format_utc_micros(snapshot.created_at_micros),
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at: format_utc_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_record(
|
||||
snapshot: RuntimeProfilePlayedWorldSnapshot,
|
||||
) -> RuntimeProfilePlayedWorldRecord {
|
||||
RuntimeProfilePlayedWorldRecord {
|
||||
played_world_id: snapshot.played_world_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_title: snapshot.world_title,
|
||||
world_subtitle: snapshot.world_subtitle,
|
||||
first_played_at: format_utc_micros(snapshot.first_played_at_micros),
|
||||
first_played_at_micros: snapshot.first_played_at_micros,
|
||||
last_played_at: format_utc_micros(snapshot.last_played_at_micros),
|
||||
last_played_at_micros: snapshot.last_played_at_micros,
|
||||
last_observed_play_time_ms: snapshot.last_observed_play_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_play_stats_record(
|
||||
snapshot: RuntimeProfilePlayStatsSnapshot,
|
||||
) -> RuntimeProfilePlayStatsRecord {
|
||||
RuntimeProfilePlayStatsRecord {
|
||||
user_id: snapshot.user_id,
|
||||
total_play_time_ms: snapshot.total_play_time_ms,
|
||||
played_works: snapshot
|
||||
.played_works
|
||||
.into_iter()
|
||||
.map(build_runtime_profile_played_world_record)
|
||||
.collect(),
|
||||
updated_at: snapshot.updated_at_micros.map(format_utc_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_snapshot_record(
|
||||
snapshot: RuntimeSnapshot,
|
||||
) -> Result<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
|
||||
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story = parse_optional_json_value(
|
||||
snapshot.current_story_json.as_deref(),
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeSnapshotRecord {
|
||||
user_id: snapshot.user_id,
|
||||
version: snapshot.version,
|
||||
saved_at: format_utc_micros(snapshot.saved_at_micros),
|
||||
saved_at_micros: snapshot.saved_at_micros,
|
||||
bottom_tab: snapshot.bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: snapshot.game_state_json,
|
||||
current_story_json: snapshot.current_story_json,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_record(
|
||||
snapshot: RuntimeProfileSaveArchiveSnapshot,
|
||||
) -> Result<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
|
||||
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story = parse_optional_json_value(
|
||||
snapshot.current_story_json.as_deref(),
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeProfileSaveArchiveRecord {
|
||||
archive_id: snapshot.archive_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
saved_at: format_utc_micros(snapshot.saved_at_micros),
|
||||
saved_at_micros: snapshot.saved_at_micros,
|
||||
bottom_tab: snapshot.bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: snapshot.game_state_json,
|
||||
current_story_json: snapshot.current_story_json,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_save_checkpoint_update(
|
||||
input: RuntimeSaveCheckpointInput,
|
||||
existing: RuntimeSnapshotRecord,
|
||||
) -> Result<RuntimeSaveCheckpointSnapshotUpdate, RuntimeProfileFieldError> {
|
||||
if is_non_persistent_runtime_snapshot(&existing.game_state) {
|
||||
return Err(RuntimeProfileFieldError::NonPersistentRuntimeSnapshot);
|
||||
}
|
||||
|
||||
let persisted_session_id =
|
||||
read_runtime_json_string_field(&existing.game_state, "runtimeSessionId")
|
||||
.ok_or(RuntimeProfileFieldError::MissingRuntimeSessionId)?;
|
||||
if persisted_session_id != input.session_id {
|
||||
return Err(RuntimeProfileFieldError::RuntimeSessionMismatch {
|
||||
expected_session_id: persisted_session_id,
|
||||
actual_session_id: input.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RuntimeSaveCheckpointSnapshotUpdate {
|
||||
saved_at_micros: input.saved_at_micros,
|
||||
bottom_tab: input.bottom_tab,
|
||||
game_state: refresh_runtime_snapshot_play_time(
|
||||
existing.game_state,
|
||||
input.updated_at_micros,
|
||||
),
|
||||
current_story: existing.current_story,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_played_world_id(user_id: &str, world_key: &str) -> String {
|
||||
format!("{}:{}", user_id.trim(), world_key.trim())
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_snapshot_wallet_ledger_id(
|
||||
user_id: &str,
|
||||
saved_at_micros: i64,
|
||||
next_wallet_balance: u64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
user_id.trim(),
|
||||
saved_at_micros,
|
||||
next_wallet_balance
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_id(user_id: &str, world_key: &str) -> String {
|
||||
format!("{}:{}", user_id.trim(), world_key.trim())
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_wallet_ledger_id(
|
||||
user_id: &str,
|
||||
created_at_micros: i64,
|
||||
product_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
user_id.trim(),
|
||||
created_at_micros,
|
||||
product_id.trim()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_order_id(
|
||||
user_id: &str,
|
||||
created_at_micros: i64,
|
||||
product_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"recharge:{}",
|
||||
build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_points_recharge_delta(
|
||||
product: &RuntimeProfileRechargeProductSnapshot,
|
||||
has_points_recharged: bool,
|
||||
) -> u64 {
|
||||
let bonus_points = if has_points_recharged {
|
||||
0
|
||||
} else {
|
||||
product.bonus_points
|
||||
};
|
||||
product.points_amount.saturating_add(bonus_points)
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_membership_purchase_update(
|
||||
current_started_at_micros: Option<i64>,
|
||||
current_expires_at_micros: Option<i64>,
|
||||
purchased_at_micros: i64,
|
||||
duration_days: u32,
|
||||
) -> RuntimeProfileMembershipPurchaseUpdate {
|
||||
let start_at_micros = current_expires_at_micros
|
||||
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
|
||||
.unwrap_or(purchased_at_micros);
|
||||
let expires_at_micros = start_at_micros
|
||||
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS));
|
||||
|
||||
RuntimeProfileMembershipPurchaseUpdate {
|
||||
started_at_micros: current_started_at_micros.unwrap_or(purchased_at_micros),
|
||||
expires_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_invite_code(user_id: &str, salt: u32) -> String {
|
||||
let mut hash = 14_695_981_039_346_656_037u64;
|
||||
for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(1_099_511_628_211);
|
||||
}
|
||||
format!("SY{:08X}", hash as u32)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_invite_link_path(invite_code: &str) -> String {
|
||||
format!("/?inviteCode={}", invite_code.trim())
|
||||
}
|
||||
|
||||
pub fn runtime_profile_day_start_micros(now_micros: i64) -> i64 {
|
||||
now_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS) * PROFILE_RUNTIME_DAY_MICROS
|
||||
}
|
||||
|
||||
pub fn should_grant_runtime_profile_inviter_reward(today_inviter_reward_count: u32) -> bool {
|
||||
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_referral_invitee_ledger_id(
|
||||
invitee_user_id: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> String {
|
||||
format!("invitee:{}:{}", invitee_user_id.trim(), updated_at_micros)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_referral_inviter_ledger_id(
|
||||
inviter_user_id: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> String {
|
||||
format!("inviter:{}:{}", inviter_user_id.trim(), updated_at_micros)
|
||||
}
|
||||
|
||||
pub fn validate_runtime_profile_redeem_code_usage(
|
||||
code: &RuntimeProfileRedeemCodeSnapshot,
|
||||
user_id: &str,
|
||||
user_used_count: u32,
|
||||
) -> Result<(), RuntimeProfileFieldError> {
|
||||
if !code.enabled {
|
||||
return Err(RuntimeProfileFieldError::RedeemCodeDisabled);
|
||||
}
|
||||
if code.reward_points == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward);
|
||||
}
|
||||
|
||||
match code.mode {
|
||||
RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => {
|
||||
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Unique if code.global_used_count >= code.max_uses => {
|
||||
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
|
||||
}
|
||||
RuntimeProfileRedeemCodeMode::Private => {
|
||||
if !code.allowed_user_ids.iter().any(|item| item == user_id) {
|
||||
return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser);
|
||||
}
|
||||
if code.global_used_count >= code.max_uses {
|
||||
return Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_usage_id(
|
||||
code: &str,
|
||||
user_id: &str,
|
||||
redeemed_at_micros: i64,
|
||||
sequence: u32,
|
||||
) -> String {
|
||||
format!(
|
||||
"redeem:{}:{}:{}:{}",
|
||||
code.trim(),
|
||||
user_id.trim(),
|
||||
redeemed_at_micros,
|
||||
sequence
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_redeem_code_ledger_id(usage_id: &str) -> String {
|
||||
format!("{}:ledger", usage_id.trim())
|
||||
}
|
||||
|
||||
pub fn convert_runtime_profile_wallet_unsigned_delta(
|
||||
amount_delta: u64,
|
||||
) -> Result<i64, RuntimeProfileFieldError> {
|
||||
i64::try_from(amount_delta).map_err(|_| RuntimeProfileFieldError::WalletAmountOverflow)
|
||||
}
|
||||
|
||||
pub fn calculate_runtime_profile_wallet_balance(
|
||||
previous_balance: u64,
|
||||
amount_delta: i64,
|
||||
) -> Result<u64, RuntimeProfileFieldError> {
|
||||
if amount_delta >= 0 {
|
||||
previous_balance
|
||||
.checked_add(amount_delta as u64)
|
||||
.ok_or(RuntimeProfileFieldError::WalletBalanceOverflow)
|
||||
} else {
|
||||
previous_balance
|
||||
.checked_sub(amount_delta.unsigned_abs())
|
||||
.ok_or(RuntimeProfileFieldError::InsufficientWalletBalance)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_runtime_snapshot_play_time(mut game_state: Value, now_micros: i64) -> Value {
|
||||
let Some(game_state_object) = game_state.as_object_mut() else {
|
||||
return game_state;
|
||||
};
|
||||
let now_text = format_utc_micros(now_micros);
|
||||
let Some(runtime_stats) = game_state_object
|
||||
.get_mut("runtimeStats")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
game_state_object.insert(
|
||||
"runtimeStats".to_string(),
|
||||
serde_json::json!({
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": now_text,
|
||||
"hostileNpcsDefeated": 0,
|
||||
"questsAccepted": 0,
|
||||
"itemsUsed": 0,
|
||||
"scenesTraveled": 0,
|
||||
}),
|
||||
);
|
||||
return game_state;
|
||||
};
|
||||
|
||||
let current_play_time = runtime_stats
|
||||
.get("playTimeMs")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0);
|
||||
let elapsed_ms = runtime_stats
|
||||
.get("lastPlayTickAt")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|last_tick| parse_rfc3339(last_tick).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.map(|last_tick_micros| now_micros.saturating_sub(last_tick_micros).max(0) as f64 / 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
let next_play_time = (current_play_time + elapsed_ms).floor().max(0.0);
|
||||
|
||||
// checkpoint 只刷新服务端已有 runtimeStats 的时间水位,不接收浏览器上传的剧情、背包或战斗真相。
|
||||
runtime_stats.insert("playTimeMs".to_string(), Value::from(next_play_time as i64));
|
||||
runtime_stats.insert("lastPlayTickAt".to_string(), Value::String(now_text));
|
||||
game_state
|
||||
}
|
||||
|
||||
pub fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
read_runtime_json_string_field_from_map(game_state, "runtimeMode").as_deref(),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_world_snapshot_meta(
|
||||
game_state: Option<&serde_json::Map<String, Value>>,
|
||||
) -> Option<RuntimeProfileWorldSnapshotMeta> {
|
||||
let game_state = game_state?;
|
||||
let custom_world_profile = game_state
|
||||
.get("customWorldProfile")
|
||||
.and_then(Value::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let profile_id = read_runtime_json_string_field_from_map(custom_world_profile, "id");
|
||||
let world_title = read_runtime_json_string_field_from_map(custom_world_profile, "name")
|
||||
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"));
|
||||
if profile_id.is_some() || world_title.is_some() {
|
||||
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
|
||||
return Some(RuntimeProfileWorldSnapshotMeta {
|
||||
world_key: profile_id
|
||||
.as_ref()
|
||||
.map(|profile_id| format!("custom:{profile_id}"))
|
||||
.unwrap_or_else(|| format!("custom:{world_title}")),
|
||||
owner_user_id: None,
|
||||
profile_id,
|
||||
world_type: Some("CUSTOM".to_string()),
|
||||
world_title,
|
||||
world_subtitle: read_runtime_json_string_field_from_map(
|
||||
custom_world_profile,
|
||||
"summary",
|
||||
)
|
||||
.or_else(|| {
|
||||
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let world_type = read_runtime_json_string_field_from_map(game_state, "worldType")?;
|
||||
let current_scene_preset = game_state
|
||||
.get("currentScenePreset")
|
||||
.and_then(Value::as_object);
|
||||
|
||||
Some(RuntimeProfileWorldSnapshotMeta {
|
||||
world_key: format!("builtin:{world_type}"),
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
world_type: Some(world_type.clone()),
|
||||
world_title: current_scene_preset
|
||||
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "name"))
|
||||
.unwrap_or_else(|| build_runtime_builtin_world_title(&world_type)),
|
||||
world_subtitle: current_scene_preset
|
||||
.and_then(|preset| {
|
||||
read_runtime_json_string_field_from_map(preset, "summary")
|
||||
.or_else(|| read_runtime_json_string_field_from_map(preset, "description"))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_save_archive_meta(
|
||||
game_state: &Value,
|
||||
current_story_json: Option<&str>,
|
||||
) -> Option<RuntimeProfileSaveArchiveMeta> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_state_object = game_state.as_object();
|
||||
let world_meta = resolve_runtime_profile_world_snapshot_meta(game_state_object)?;
|
||||
let story_engine_memory = game_state_object
|
||||
.and_then(|state| state.get("storyEngineMemory"))
|
||||
.and_then(Value::as_object);
|
||||
let continue_game_digest = story_engine_memory
|
||||
.and_then(|memory| read_runtime_json_string_field_from_map(memory, "continueGameDigest"));
|
||||
let current_story_text = parse_optional_json_value(
|
||||
current_story_json,
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|story| story.as_object().cloned())
|
||||
.and_then(|story| read_runtime_json_string_field_from_map(&story, "text"));
|
||||
let custom_world_profile = game_state_object
|
||||
.and_then(|state| state.get("customWorldProfile"))
|
||||
.and_then(Value::as_object);
|
||||
|
||||
if let Some(custom_world_profile) = custom_world_profile {
|
||||
let world_name = read_runtime_json_string_field_from_map(custom_world_profile, "name")
|
||||
.or_else(|| read_runtime_json_string_field_from_map(custom_world_profile, "title"))
|
||||
.unwrap_or_else(|| world_meta.world_title.clone());
|
||||
let subtitle = read_runtime_json_string_field_from_map(custom_world_profile, "summary")
|
||||
.or_else(|| {
|
||||
read_runtime_json_string_field_from_map(custom_world_profile, "settingText")
|
||||
})
|
||||
.unwrap_or_else(|| world_meta.world_subtitle.clone());
|
||||
let summary_text = continue_game_digest
|
||||
.or(current_story_text)
|
||||
.or_else(|| {
|
||||
if subtitle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(subtitle.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||||
|
||||
return Some(RuntimeProfileSaveArchiveMeta {
|
||||
world_key: world_meta.world_key,
|
||||
owner_user_id: world_meta.owner_user_id,
|
||||
profile_id: world_meta.profile_id,
|
||||
world_type: world_meta.world_type,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
cover_image_src: read_runtime_json_string_field_from_map(
|
||||
custom_world_profile,
|
||||
"coverImageSrc",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let summary_text = continue_game_digest
|
||||
.or(current_story_text)
|
||||
.or_else(|| {
|
||||
if world_meta.world_subtitle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(world_meta.world_subtitle.clone())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
|
||||
let current_scene_preset = game_state_object
|
||||
.and_then(|state| state.get("currentScenePreset"))
|
||||
.and_then(Value::as_object);
|
||||
|
||||
Some(RuntimeProfileSaveArchiveMeta {
|
||||
world_key: world_meta.world_key,
|
||||
owner_user_id: world_meta.owner_user_id,
|
||||
profile_id: world_meta.profile_id,
|
||||
world_type: world_meta.world_type,
|
||||
world_name: world_meta.world_title,
|
||||
subtitle: world_meta.world_subtitle,
|
||||
summary_text,
|
||||
cover_image_src: current_scene_preset
|
||||
.and_then(|preset| read_runtime_json_string_field_from_map(preset, "imageSrc")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_runtime_json_non_negative_u64(value: Option<&Value>) -> u64 {
|
||||
match value {
|
||||
Some(Value::Number(number)) => {
|
||||
if let Some(raw) = number.as_u64() {
|
||||
raw
|
||||
} else if let Some(raw) = number.as_i64() {
|
||||
raw.max(0) as u64
|
||||
} else if let Some(raw) = number.as_f64() {
|
||||
if raw.is_finite() && raw > 0.0 {
|
||||
raw.floor() as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Some(Value::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_runtime_json_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
read_runtime_json_string_field_from_map(value.as_object()?, field)
|
||||
}
|
||||
|
||||
pub fn read_runtime_json_string_field_from_map(
|
||||
value: &serde_json::Map<String, Value>,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
value
|
||||
.get(field)?
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn build_runtime_builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
"XIANXIA" => "仙侠世界".to_string(),
|
||||
_ => "叙事世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_json_value(
|
||||
raw: Option<&str>,
|
||||
error: RuntimeProfileFieldError,
|
||||
) -> Result<Option<Value>, RuntimeProfileFieldError> {
|
||||
match raw.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(value) => serde_json::from_str::<Value>(value)
|
||||
.map(Some)
|
||||
.map_err(|_| error),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_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)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,107 @@
|
||||
//! 运行时领域错误过渡落位。
|
||||
//!
|
||||
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
|
||||
|
||||
use crate::MAX_BROWSE_HISTORY_BATCH_SIZE;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeSettingsFieldError {
|
||||
MissingUserId,
|
||||
}
|
||||
|
||||
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 不能为空"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeBrowseHistoryFieldError {
|
||||
MissingUserId,
|
||||
TooManyEntries,
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeProfileFieldError {
|
||||
MissingUserId,
|
||||
MissingLedgerId,
|
||||
InvalidWalletAmount,
|
||||
WalletAmountOverflow,
|
||||
WalletBalanceOverflow,
|
||||
InsufficientWalletBalance,
|
||||
MissingInviteCode,
|
||||
MissingRedeemCode,
|
||||
RedeemCodeDisabled,
|
||||
RedeemCodeUsesExhausted,
|
||||
RedeemCodeNotAllowedForUser,
|
||||
InvalidRedeemCodeReward,
|
||||
InvalidRedeemCodeMaxUses,
|
||||
MissingProductId,
|
||||
MissingWorldKey,
|
||||
MissingBottomTab,
|
||||
MissingCheckpointSessionId,
|
||||
UnknownRechargeProduct,
|
||||
InvalidGameStateJson,
|
||||
InvalidCurrentStoryJson,
|
||||
MissingRuntimeSessionId,
|
||||
RuntimeSessionMismatch {
|
||||
expected_session_id: String,
|
||||
actual_session_id: String,
|
||||
},
|
||||
NonPersistentRuntimeSnapshot,
|
||||
}
|
||||
|
||||
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 不能为空"),
|
||||
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
|
||||
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
|
||||
Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"),
|
||||
Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"),
|
||||
Self::InsufficientWalletBalance => f.write_str("叙世币余额不足"),
|
||||
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
|
||||
Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
|
||||
Self::RedeemCodeDisabled => f.write_str("兑换码已停用"),
|
||||
Self::RedeemCodeUsesExhausted => f.write_str("兑换次数已用完"),
|
||||
Self::RedeemCodeNotAllowedForUser => f.write_str("该兑换码不适用于当前账号"),
|
||||
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
|
||||
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
|
||||
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),
|
||||
Self::UnknownRechargeProduct => f.write_str("recharge.product_id 不存在"),
|
||||
Self::InvalidGameStateJson => {
|
||||
f.write_str("runtime_snapshot.game_state 必须是合法 JSON")
|
||||
}
|
||||
Self::InvalidCurrentStoryJson => {
|
||||
f.write_str("runtime_snapshot.current_story 必须是合法 JSON object 或 null")
|
||||
}
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("服务端运行时快照缺少 runtimeSessionId,无法创建 checkpoint")
|
||||
}
|
||||
Self::RuntimeSessionMismatch { .. } => {
|
||||
f.write_str("checkpoint sessionId 与服务端运行时快照不一致")
|
||||
}
|
||||
Self::NonPersistentRuntimeSnapshot => {
|
||||
f.write_str("预览或测试运行态不能创建正式 checkpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user