Files
Genarrative/server-rs/crates/module-runtime/src/commands.rs
2026-05-10 22:20:54 +08:00

852 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 运行时写入命令。
//!
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
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};
pub const PROFILE_USER_TAG_MAX_COUNT: usize = 8;
pub const PROFILE_USER_TAG_MAX_CHARS: usize = 16;
// 统一把共享必填字符串归一化映射到 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_tracking_event_input(
event_id: String,
event_key: String,
scope_kind: RuntimeTrackingScopeKind,
scope_id: String,
user_id: Option<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
module_key: Option<String>,
metadata_json: String,
occurred_at_micros: i64,
) -> Result<RuntimeTrackingEventInput, RuntimeProfileFieldError> {
let event_id = normalize_required_string(event_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?;
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
let metadata_json = normalize_tracking_metadata_json(metadata_json)?;
Ok(RuntimeTrackingEventInput {
event_id,
event_key,
scope_kind,
scope_id,
user_id: normalize_optional_string(user_id),
owner_user_id: normalize_optional_string(owner_user_id),
profile_id: normalize_optional_string(profile_id),
module_key: normalize_optional_string(module_key),
metadata_json,
occurred_at_micros,
})
}
pub fn build_runtime_profile_task_center_get_input(
user_id: String,
) -> Result<RuntimeProfileTaskCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileTaskCenterGetInput { user_id })
}
pub fn build_analytics_metric_query_input(
event_key: String,
scope_kind: RuntimeTrackingScopeKind,
scope_id: String,
granularity: AnalyticsGranularity,
) -> Result<AnalyticsMetricQueryInput, RuntimeProfileFieldError> {
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
Ok(AnalyticsMetricQueryInput {
event_key,
scope_kind,
scope_id,
granularity,
})
}
pub fn build_runtime_profile_task_claim_input(
user_id: String,
task_id: String,
) -> Result<RuntimeProfileTaskClaimInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
Ok(RuntimeProfileTaskClaimInput { user_id, task_id })
}
pub fn build_runtime_profile_task_config_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileTaskConfigAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileTaskConfigAdminListInput { admin_user_id })
}
#[allow(clippy::too_many_arguments)]
pub fn build_runtime_profile_task_config_admin_upsert_input(
admin_user_id: String,
task_id: String,
title: String,
description: String,
event_key: String,
cycle: RuntimeProfileTaskCycle,
scope_kind: RuntimeTrackingScopeKind,
threshold: u32,
reward_points: u64,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?;
let event_key = normalize_required_string(event_key)
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
// 中文注释:个人任务首版只按用户维度累计,避免 site/work/module 误复用用户桶。
if scope_kind != RuntimeTrackingScopeKind::User {
return Err(RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind);
}
if threshold == 0 {
return Err(RuntimeProfileFieldError::InvalidTaskThreshold);
}
if reward_points == 0 || reward_points > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidTaskReward);
}
Ok(RuntimeProfileTaskConfigAdminUpsertInput {
admin_user_id,
task_id,
title,
description: normalize_optional_string(Some(description)).unwrap_or_default(),
event_key,
cycle,
scope_kind,
threshold,
reward_points,
enabled,
sort_order,
updated_at_micros,
})
}
pub fn build_runtime_profile_task_config_admin_disable_input(
admin_user_id: String,
task_id: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigAdminDisableInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
Ok(RuntimeProfileTaskConfigAdminDisableInput {
admin_user_id,
task_id,
updated_at_micros,
})
}
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_profile_feedback_submission_input(
user_id: String,
description: String,
contact_phone: Option<String>,
evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
created_at_micros: i64,
) -> Result<RuntimeProfileFeedbackSubmissionInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let description = normalize_required_string(description)
.ok_or(RuntimeProfileFieldError::MissingFeedbackDescription)?;
let description_chars = description.chars().count();
if description_chars < PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS {
return Err(RuntimeProfileFieldError::FeedbackDescriptionTooShort);
}
if description_chars > PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS {
return Err(RuntimeProfileFieldError::FeedbackDescriptionTooLong);
}
let contact_phone = normalize_optional_string(contact_phone);
if contact_phone
.as_deref()
.map(|value| value.chars().count() > PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS)
.unwrap_or(false)
{
return Err(RuntimeProfileFieldError::FeedbackContactPhoneTooLong);
}
if evidence_items.len() > PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT {
return Err(RuntimeProfileFieldError::TooManyFeedbackEvidenceItems);
}
let feedback_id = build_runtime_profile_feedback_submission_id(&user_id, created_at_micros);
let mut total_size_bytes = 0u64;
let mut normalized_evidence_items = Vec::with_capacity(evidence_items.len());
for (index, item) in evidence_items.into_iter().enumerate() {
let content_type = normalize_required_string(item.content_type)
.map(|value| value.to_ascii_lowercase())
.ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType)?;
if !is_profile_feedback_image_content_type(&content_type) {
return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType);
}
if item.size_bytes == 0 || item.size_bytes > PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES {
return Err(RuntimeProfileFieldError::FeedbackEvidenceTooLarge);
}
total_size_bytes = total_size_bytes
.checked_add(item.size_bytes)
.ok_or(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge)?;
if total_size_bytes > PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES {
return Err(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge);
}
let data_url = normalize_required_string(item.data_url)
.ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)?;
if !has_profile_feedback_data_url_prefix(&data_url, &content_type) {
return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl);
}
let file_name = normalize_optional_string(Some(item.file_name))
.unwrap_or_else(|| format!("feedback-image-{}", index + 1));
normalized_evidence_items.push(RuntimeProfileFeedbackEvidenceSnapshot {
evidence_id: build_runtime_profile_feedback_evidence_id(&feedback_id, index),
file_name,
content_type,
size_bytes: item.size_bytes,
data_url,
});
}
Ok(RuntimeProfileFeedbackSubmissionInput {
user_id,
description,
contact_phone,
evidence_items: normalized_evidence_items,
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_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileRedeemCodeAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileRedeemCodeAdminListInput { admin_user_id })
}
pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
granted_user_tags: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
) -> Result<RuntimeProfileInviteCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
let granted_user_tags = normalize_profile_user_tags(granted_user_tags)?;
crate::commands::validate_runtime_profile_invite_code_validity_window(
starts_at_micros,
expires_at_micros,
)?;
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
admin_user_id,
invite_code,
metadata_json,
granted_user_tags,
starts_at_micros,
expires_at_micros,
updated_at_micros,
})
}
pub fn build_runtime_profile_invite_code_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileInviteCodeAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileInviteCodeAdminListInput { admin_user_id })
}
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}")
}
pub fn build_runtime_profile_feedback_submission_id(
user_id: &str,
created_at_micros: i64,
) -> String {
format!("feedback:{}:{}", user_id.trim(), created_at_micros)
}
pub fn build_runtime_profile_feedback_evidence_id(feedback_id: &str, index: usize) -> String {
format!("{}:evidence:{:02}", feedback_id.trim(), index + 1)
}
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)
}
fn is_profile_feedback_image_content_type(value: &str) -> bool {
matches!(
value,
"image/png" | "image/jpeg" | "image/jpg" | "image/webp" | "image/gif"
)
}
fn has_profile_feedback_data_url_prefix(data_url: &str, content_type: &str) -> bool {
data_url
.to_ascii_lowercase()
.starts_with(&format!("data:{content_type};base64,"))
}
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)
}
pub fn normalize_invite_code_metadata_json(
value: String,
) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string());
}
if trimmed.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
let parsed = serde_json::from_str::<Value>(trimmed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if !parsed.is_object() {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
pub fn normalize_profile_user_tags(
values: Vec<String>,
) -> Result<Vec<String>, RuntimeProfileFieldError> {
let mut tags = Vec::new();
for value in values {
let Some(tag) = normalize_optional_string(Some(value)) else {
continue;
};
if tag.chars().count() > PROFILE_USER_TAG_MAX_CHARS {
return Err(RuntimeProfileFieldError::InvalidUserTag);
}
if !tags.iter().any(|existing| existing == &tag) {
tags.push(tag);
}
if tags.len() > PROFILE_USER_TAG_MAX_COUNT {
return Err(RuntimeProfileFieldError::InvalidUserTag);
}
}
Ok(tags)
}
pub fn validate_runtime_profile_invite_code_validity_window(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
) -> Result<(), RuntimeProfileFieldError> {
if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at)
{
return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow);
}
Ok(())
}
pub fn resolve_runtime_profile_invite_code_status(
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
now_micros: i64,
) -> RuntimeProfileInviteCodeStatus {
if starts_at_micros
.map(|starts_at| now_micros < starts_at)
.unwrap_or(false)
{
return RuntimeProfileInviteCodeStatus::Pending;
}
if expires_at_micros
.map(|expires_at| now_micros >= expires_at)
.unwrap_or(false)
{
return RuntimeProfileInviteCodeStatus::Expired;
}
RuntimeProfileInviteCodeStatus::Active
}
fn normalize_tracking_metadata_json(value: String) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string());
}
let parsed = serde_json::from_str::<Value>(trimmed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if !parsed.is_object() {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFieldError> {
normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId)
}