852 lines
30 KiB
Rust
852 lines
30 KiB
Rust
//! 运行时写入命令。
|
||
//!
|
||
//! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。
|
||
|
||
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(¤t_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)
|
||
}
|