a597fcd076
补齐退款事实对比字段与人工复核审计展示 人工复核重放严格校验管理员、原因和错误码 退款占用重试完整匹配管理员与归一化原因 统一退款原因持久化上限并修复刷新恢复 补充前后端回归测试、生成绑定与数据契约文档
1547 lines
54 KiB
Rust
1547 lines
54 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;
|
|
|
|
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_profile_wallet_config_admin_get_input(
|
|
admin_user_id: String,
|
|
) -> Result<RuntimeProfileWalletConfigAdminGetInput, RuntimeProfileFieldError> {
|
|
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
|
Ok(RuntimeProfileWalletConfigAdminGetInput { admin_user_id })
|
|
}
|
|
|
|
pub fn build_runtime_profile_wallet_config_admin_upsert_input(
|
|
admin_user_id: String,
|
|
initial_mud_points: u64,
|
|
updated_at_micros: i64,
|
|
) -> Result<RuntimeProfileWalletConfigAdminUpsertInput, RuntimeProfileFieldError> {
|
|
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
|
if initial_mud_points == 0 || initial_mud_points > i64::MAX as u64 {
|
|
return Err(RuntimeProfileFieldError::InvalidInitialWalletPoints);
|
|
}
|
|
Ok(RuntimeProfileWalletConfigAdminUpsertInput {
|
|
admin_user_id,
|
|
initial_mud_points,
|
|
updated_at_micros,
|
|
})
|
|
}
|
|
|
|
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> {
|
|
build_runtime_profile_wallet_adjustment_input_with_metadata(
|
|
user_id,
|
|
amount,
|
|
ledger_id,
|
|
created_at_micros,
|
|
PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
|
|
)
|
|
}
|
|
|
|
pub fn build_runtime_profile_wallet_adjustment_input_with_metadata(
|
|
user_id: String,
|
|
amount: u64,
|
|
ledger_id: String,
|
|
created_at_micros: i64,
|
|
metadata_json: String,
|
|
) -> 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);
|
|
}
|
|
let metadata_json = normalize_wallet_metadata_json(metadata_json)?;
|
|
Ok(RuntimeProfileWalletAdjustmentInput {
|
|
user_id,
|
|
amount,
|
|
ledger_id,
|
|
created_at_micros,
|
|
metadata_json,
|
|
})
|
|
}
|
|
|
|
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_get_input(
|
|
order_id: String,
|
|
) -> Result<RuntimeProfileRechargeOrderGetInput, RuntimeProfileFieldError> {
|
|
let order_id =
|
|
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
|
Ok(RuntimeProfileRechargeOrderGetInput { order_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)?;
|
|
let payment_channel = normalize_required_string(payment_channel)
|
|
.ok_or(RuntimeProfileFieldError::MissingPaymentChannel)?;
|
|
|
|
Ok(RuntimeProfileRechargeOrderCreateInput {
|
|
user_id,
|
|
product_id,
|
|
payment_channel,
|
|
created_at_micros,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_product_admin_list_input(
|
|
admin_user_id: String,
|
|
) -> Result<RuntimeProfileRechargeProductAdminListInput, RuntimeProfileFieldError> {
|
|
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
|
Ok(RuntimeProfileRechargeProductAdminListInput { admin_user_id })
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_runtime_profile_recharge_product_admin_upsert_input(
|
|
admin_user_id: String,
|
|
product_id: String,
|
|
title: String,
|
|
price_cents: u64,
|
|
kind: RuntimeProfileRechargeProductKind,
|
|
points_amount: u64,
|
|
bonus_points: u64,
|
|
duration_days: u32,
|
|
badge_label: String,
|
|
description: String,
|
|
tier: RuntimeProfileMembershipTier,
|
|
membership_period_points: u64,
|
|
membership_period_days: u32,
|
|
membership_queue_limit: u32,
|
|
membership_discount_bps: u32,
|
|
enabled: bool,
|
|
sort_order: i32,
|
|
updated_at_micros: i64,
|
|
) -> Result<RuntimeProfileRechargeProductAdminUpsertInput, RuntimeProfileFieldError> {
|
|
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
|
let product_id =
|
|
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
|
|
let title =
|
|
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?;
|
|
if price_cents == 0 {
|
|
return Err(RuntimeProfileFieldError::InvalidRechargeProductPrice);
|
|
}
|
|
match kind {
|
|
RuntimeProfileRechargeProductKind::Points => {
|
|
if points_amount == 0 {
|
|
return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints);
|
|
}
|
|
if duration_days != 0
|
|
|| tier != RuntimeProfileMembershipTier::Normal
|
|
|| membership_period_points != 0
|
|
|| membership_period_days != 0
|
|
|| membership_queue_limit != 0
|
|
|| membership_discount_bps != 0
|
|
{
|
|
return Err(RuntimeProfileFieldError::InvalidRechargeProductTier);
|
|
}
|
|
}
|
|
RuntimeProfileRechargeProductKind::Membership => {
|
|
if duration_days == 0 {
|
|
return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration);
|
|
}
|
|
if points_amount != 0
|
|
|| bonus_points != 0
|
|
|| tier == RuntimeProfileMembershipTier::Normal
|
|
|| membership_period_points == 0
|
|
|| membership_period_days == 0
|
|
{
|
|
return Err(RuntimeProfileFieldError::InvalidRechargeProductTier);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(RuntimeProfileRechargeProductAdminUpsertInput {
|
|
admin_user_id,
|
|
product_id,
|
|
title,
|
|
price_cents,
|
|
kind,
|
|
points_amount,
|
|
bonus_points,
|
|
duration_days,
|
|
badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(),
|
|
description: normalize_optional_string(Some(description)).unwrap_or_default(),
|
|
tier,
|
|
membership_period_points,
|
|
membership_period_days,
|
|
membership_queue_limit,
|
|
membership_discount_bps,
|
|
enabled,
|
|
sort_order,
|
|
updated_at_micros,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_paid_input(
|
|
order_id: String,
|
|
paid_at_micros: i64,
|
|
provider_transaction_id: Option<String>,
|
|
) -> Result<RuntimeProfileRechargeOrderPaidInput, RuntimeProfileFieldError> {
|
|
let order_id =
|
|
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
|
Ok(RuntimeProfileRechargeOrderPaidInput {
|
|
order_id,
|
|
paid_at_micros,
|
|
provider_transaction_id: provider_transaction_id.and_then(normalize_required_string),
|
|
})
|
|
}
|
|
|
|
fn normalize_profile_recharge_refund_identifier(
|
|
value: String,
|
|
field_name: &str,
|
|
) -> Result<String, String> {
|
|
let normalized =
|
|
normalize_required_string(value).ok_or_else(|| format!("{field_name} 不能为空"))?;
|
|
if normalized.len() > 128 {
|
|
return Err(format!("{field_name} 超出 128 bytes 上限"));
|
|
}
|
|
Ok(normalized)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_runtime_profile_recharge_refund_observation_input(
|
|
observation_id: String,
|
|
source: RuntimeProfileRechargeRefundObservationSource,
|
|
notification_ref: Option<String>,
|
|
payload_fingerprint: String,
|
|
out_refund_no: String,
|
|
provider_refund_id: String,
|
|
order_id: String,
|
|
provider_transaction_id: String,
|
|
provider_status: RuntimeProfileRechargeRefundStatus,
|
|
total_cents: u64,
|
|
refund_cents: u64,
|
|
payer_total_cents: u64,
|
|
payer_refund_cents: u64,
|
|
success_at_micros: Option<i64>,
|
|
observed_at_micros: i64,
|
|
) -> Result<RuntimeProfileRechargeRefundObservationInput, String> {
|
|
let observation_id =
|
|
normalize_profile_recharge_refund_identifier(observation_id, "refund.observation_id")?;
|
|
let payload_fingerprint = normalize_profile_recharge_refund_identifier(
|
|
payload_fingerprint,
|
|
"refund.payload_fingerprint",
|
|
)?;
|
|
let out_refund_no =
|
|
normalize_profile_recharge_refund_identifier(out_refund_no, "refund.out_refund_no")?;
|
|
let provider_refund_id = normalize_profile_recharge_refund_identifier(
|
|
provider_refund_id,
|
|
"refund.provider_refund_id",
|
|
)?;
|
|
let order_id = normalize_profile_recharge_refund_identifier(order_id, "refund.order_id")?;
|
|
let provider_transaction_id = normalize_profile_recharge_refund_identifier(
|
|
provider_transaction_id,
|
|
"refund.provider_transaction_id",
|
|
)?;
|
|
if total_cents == 0 {
|
|
return Err("refund.total_cents 必须大于 0".to_string());
|
|
}
|
|
if refund_cents == 0 || refund_cents > total_cents {
|
|
return Err("refund.refund_cents 必须大于 0 且不超过 total_cents".to_string());
|
|
}
|
|
if payer_refund_cents > payer_total_cents {
|
|
return Err("refund.payer_refund_cents 不能超过 payer_total_cents".to_string());
|
|
}
|
|
if payer_total_cents > total_cents || payer_refund_cents > refund_cents {
|
|
return Err("refund payer 金额不能超过对应订单或退款金额".to_string());
|
|
}
|
|
if observed_at_micros <= 0 {
|
|
return Err("refund.observed_at_micros 必须大于 0".to_string());
|
|
}
|
|
if provider_status == RuntimeProfileRechargeRefundStatus::Success
|
|
&& success_at_micros.filter(|value| *value > 0).is_none()
|
|
{
|
|
return Err("SUCCESS 退款必须提供 success_at_micros".to_string());
|
|
}
|
|
|
|
Ok(RuntimeProfileRechargeRefundObservationInput {
|
|
observation_id,
|
|
source,
|
|
notification_ref: normalize_optional_string(notification_ref),
|
|
payload_fingerprint,
|
|
out_refund_no,
|
|
provider_refund_id,
|
|
order_id,
|
|
provider_transaction_id,
|
|
provider_status,
|
|
total_cents,
|
|
refund_cents,
|
|
payer_total_cents,
|
|
payer_refund_cents,
|
|
success_at_micros,
|
|
observed_at_micros,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_get_input(
|
|
out_refund_no: String,
|
|
) -> Result<RuntimeProfileRechargeRefundGetInput, String> {
|
|
Ok(RuntimeProfileRechargeRefundGetInput {
|
|
out_refund_no: normalize_profile_recharge_refund_identifier(
|
|
out_refund_no,
|
|
"refund.out_refund_no",
|
|
)?,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_reconciliation_list_input(
|
|
limit: u32,
|
|
) -> RuntimeProfileRechargeRefundReconciliationListInput {
|
|
RuntimeProfileRechargeRefundReconciliationListInput {
|
|
limit: if limit == 0 { 100 } else { limit.min(500) },
|
|
}
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_admin_list_input(
|
|
order_id: Option<String>,
|
|
user_id: Option<String>,
|
|
provider_transaction_id: Option<String>,
|
|
payment_channel: Option<String>,
|
|
status: Option<RuntimeProfileRechargeOrderStatus>,
|
|
created_after_micros: Option<i64>,
|
|
created_before_micros: Option<i64>,
|
|
limit: u32,
|
|
) -> Result<RuntimeProfileRechargeOrderAdminListInput, String> {
|
|
if let (Some(after), Some(before)) = (created_after_micros, created_before_micros)
|
|
&& (after <= 0 || before <= 0 || after > before)
|
|
{
|
|
return Err("recharge_order 查询时间范围无效".to_string());
|
|
}
|
|
Ok(RuntimeProfileRechargeOrderAdminListInput {
|
|
order_id: normalize_optional_string(order_id),
|
|
user_id: normalize_optional_string(user_id),
|
|
provider_transaction_id: normalize_optional_string(provider_transaction_id),
|
|
payment_channel: normalize_optional_string(payment_channel),
|
|
status,
|
|
created_after_micros,
|
|
created_before_micros,
|
|
limit: if limit == 0 { 100 } else { limit.min(500) },
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_hold_preview_input(
|
|
order_id: String,
|
|
refund_cents: u64,
|
|
) -> Result<RuntimeProfileRechargeRefundHoldPreviewInput, String> {
|
|
if refund_cents == 0 {
|
|
return Err("refund_hold.refund_cents 必须大于 0".to_string());
|
|
}
|
|
Ok(RuntimeProfileRechargeRefundHoldPreviewInput {
|
|
order_id: normalize_profile_recharge_refund_identifier(order_id, "refund_hold.order_id")?,
|
|
refund_cents,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_hold_prepare_input(
|
|
order_id: String,
|
|
out_refund_no: String,
|
|
refund_cents: u64,
|
|
admin_user_id: String,
|
|
reason: String,
|
|
) -> Result<RuntimeProfileRechargeRefundHoldPrepareInput, String> {
|
|
let preview = build_runtime_profile_recharge_refund_hold_preview_input(order_id, refund_cents)?;
|
|
Ok(RuntimeProfileRechargeRefundHoldPrepareInput {
|
|
order_id: preview.order_id,
|
|
out_refund_no: normalize_profile_recharge_refund_identifier(
|
|
out_refund_no,
|
|
"refund_hold.out_refund_no",
|
|
)?,
|
|
refund_cents: preview.refund_cents,
|
|
admin_user_id: normalize_profile_recharge_refund_identifier(
|
|
admin_user_id,
|
|
"refund_hold.admin_user_id",
|
|
)?,
|
|
reason: normalize_profile_recharge_refund_reason(reason, "refund_hold.reason")?,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_hold_release_input(
|
|
out_refund_no: String,
|
|
admin_user_id: String,
|
|
release_reason: String,
|
|
) -> Result<RuntimeProfileRechargeRefundHoldReleaseInput, String> {
|
|
Ok(RuntimeProfileRechargeRefundHoldReleaseInput {
|
|
out_refund_no: normalize_profile_recharge_refund_identifier(
|
|
out_refund_no,
|
|
"refund_hold.out_refund_no",
|
|
)?,
|
|
admin_user_id: normalize_profile_recharge_refund_identifier(
|
|
admin_user_id,
|
|
"refund_hold.admin_user_id",
|
|
)?,
|
|
release_reason: normalize_profile_recharge_refund_reason(
|
|
release_reason,
|
|
"refund_hold.release_reason",
|
|
)?,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_hold_list_input(
|
|
limit: u32,
|
|
) -> RuntimeProfileRechargeRefundHoldListInput {
|
|
RuntimeProfileRechargeRefundHoldListInput {
|
|
limit: if limit == 0 { 100 } else { limit.min(500) },
|
|
}
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
|
out_refund_no: String,
|
|
admin_user_id: String,
|
|
reason: String,
|
|
expected_error_code: String,
|
|
) -> Result<RuntimeProfileRechargeRefundManualReviewResolveInput, String> {
|
|
let expected_error_code = normalize_profile_recharge_refund_identifier(
|
|
expected_error_code,
|
|
"refund_manual_review.expected_error_code",
|
|
)?;
|
|
if !matches!(
|
|
expected_error_code.as_str(),
|
|
"provider_transaction_id_mismatch" | "order_total_mismatch"
|
|
) {
|
|
return Err("refund_manual_review.expected_error_code 不支持人工确认".to_string());
|
|
}
|
|
Ok(RuntimeProfileRechargeRefundManualReviewResolveInput {
|
|
out_refund_no: normalize_profile_recharge_refund_identifier(
|
|
out_refund_no,
|
|
"refund_manual_review.out_refund_no",
|
|
)?,
|
|
admin_user_id: normalize_profile_recharge_refund_identifier(
|
|
admin_user_id,
|
|
"refund_manual_review.admin_user_id",
|
|
)?,
|
|
reason: normalize_profile_recharge_refund_reason(reason, "refund_manual_review.reason")?,
|
|
expected_error_code,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_admin_wallet_get_input(
|
|
user_id: String,
|
|
) -> Result<RuntimeProfileAdminWalletGetInput, String> {
|
|
Ok(RuntimeProfileAdminWalletGetInput {
|
|
user_id: normalize_profile_recharge_refund_identifier(user_id, "wallet.user_id")?,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_wallet_manual_restriction_upsert_input(
|
|
user_id: String,
|
|
frozen: bool,
|
|
reason: String,
|
|
admin_user_id: String,
|
|
) -> Result<RuntimeProfileWalletManualRestrictionUpsertInput, String> {
|
|
Ok(RuntimeProfileWalletManualRestrictionUpsertInput {
|
|
user_id: normalize_profile_recharge_refund_identifier(user_id, "restriction.user_id")?,
|
|
frozen,
|
|
reason: normalize_profile_recharge_refund_reason(reason, "restriction.reason")?,
|
|
admin_user_id: normalize_profile_recharge_refund_identifier(
|
|
admin_user_id,
|
|
"restriction.admin_user_id",
|
|
)?,
|
|
})
|
|
}
|
|
|
|
fn normalize_profile_recharge_refund_reason(
|
|
value: String,
|
|
field_name: &str,
|
|
) -> Result<String, String> {
|
|
let normalized =
|
|
normalize_required_string(value).ok_or_else(|| format!("{field_name} 不能为空"))?;
|
|
if normalized.len() > 256 {
|
|
return Err(format!("{field_name} 超出 256 bytes 上限"));
|
|
}
|
|
Ok(normalized)
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_bill_checkpoint_get_input(
|
|
checkpoint_id: String,
|
|
) -> Result<RuntimeProfileRechargeRefundBillCheckpointGetInput, String> {
|
|
Ok(RuntimeProfileRechargeRefundBillCheckpointGetInput {
|
|
checkpoint_id: normalize_profile_recharge_refund_identifier(
|
|
checkpoint_id,
|
|
"refund_bill.checkpoint_id",
|
|
)?,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_refund_bill_checkpoint_advance_input(
|
|
checkpoint_id: String,
|
|
bill_date: String,
|
|
bill_hash: String,
|
|
processed_refund_count: u32,
|
|
completed_at_micros: i64,
|
|
) -> Result<RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, String> {
|
|
let checkpoint_id =
|
|
normalize_profile_recharge_refund_identifier(checkpoint_id, "refund_bill.checkpoint_id")?;
|
|
let bill_date =
|
|
normalize_profile_recharge_refund_identifier(bill_date, "refund_bill.bill_date")?;
|
|
crate::parse_analytics_calendar_date_key(&bill_date)
|
|
.map_err(|_| "refund_bill.bill_date 必须是合法 YYYY-MM-DD 日期".to_string())?;
|
|
let bill_hash =
|
|
normalize_profile_recharge_refund_identifier(bill_hash, "refund_bill.bill_hash")?;
|
|
if completed_at_micros <= 0 {
|
|
return Err("refund_bill.completed_at_micros 必须大于 0".to_string());
|
|
}
|
|
Ok(RuntimeProfileRechargeRefundBillCheckpointAdvanceInput {
|
|
checkpoint_id,
|
|
bill_date,
|
|
bill_hash,
|
|
processed_refund_count,
|
|
completed_at_micros,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_close_input(
|
|
order_id: String,
|
|
closed_at_micros: i64,
|
|
) -> Result<RuntimeProfileRechargeOrderCloseInput, RuntimeProfileFieldError> {
|
|
let order_id =
|
|
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
|
Ok(RuntimeProfileRechargeOrderCloseInput {
|
|
order_id,
|
|
closed_at_micros,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_expiration_claim_input(
|
|
worker_id: String,
|
|
now_micros: i64,
|
|
lease_expires_at_micros: i64,
|
|
limit: u32,
|
|
) -> Result<RuntimeProfileRechargeOrderExpirationClaimInput, RuntimeProfileFieldError> {
|
|
let worker_id =
|
|
normalize_required_string(worker_id).ok_or(RuntimeProfileFieldError::MissingWorkerId)?;
|
|
Ok(RuntimeProfileRechargeOrderExpirationClaimInput {
|
|
worker_id,
|
|
now_micros,
|
|
lease_expires_at_micros,
|
|
limit,
|
|
})
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_expiration_complete_input(
|
|
order_id: String,
|
|
) -> Result<RuntimeProfileRechargeOrderExpirationCompleteInput, RuntimeProfileFieldError> {
|
|
let order_id =
|
|
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
|
Ok(RuntimeProfileRechargeOrderExpirationCompleteInput { order_id })
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_expiration_check_list_input(
|
|
limit: u32,
|
|
) -> Result<RuntimeProfileRechargeOrderExpirationCheckListInput, RuntimeProfileFieldError> {
|
|
Ok(RuntimeProfileRechargeOrderExpirationCheckListInput { limit })
|
|
}
|
|
|
|
pub fn build_runtime_profile_recharge_order_expiration_check_input(
|
|
order_id: String,
|
|
checked_at_micros: Option<i64>,
|
|
provider_state: Option<String>,
|
|
last_error: Option<String>,
|
|
) -> Result<RuntimeProfileRechargeOrderExpirationCheckInput, RuntimeProfileFieldError> {
|
|
let order_id =
|
|
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
|
|
Ok(RuntimeProfileRechargeOrderExpirationCheckInput {
|
|
order_id,
|
|
checked_at_micros,
|
|
provider_state: provider_state.and_then(normalize_required_string),
|
|
last_error: last_error.and_then(normalize_required_string),
|
|
})
|
|
}
|
|
|
|
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>,
|
|
starts_at_micros: Option<i64>,
|
|
expires_at_micros: Option<i64>,
|
|
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);
|
|
}
|
|
validate_runtime_profile_redeem_code_validity_window(starts_at_micros, expires_at_micros)?;
|
|
|
|
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(),
|
|
starts_at_micros,
|
|
expires_at_micros,
|
|
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,
|
|
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)?;
|
|
crate::commands::validate_runtime_profile_invite_code_validity_window(
|
|
starts_at_micros,
|
|
expires_at_micros,
|
|
)?;
|
|
|
|
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
|
|
admin_user_id,
|
|
invite_code,
|
|
metadata_json,
|
|
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 mut parsed = serde_json::from_str::<Value>(trimmed)
|
|
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
|
|
if !parsed.is_object() {
|
|
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
|
|
}
|
|
|
|
normalize_invite_code_metadata_user_tags(&mut parsed)?;
|
|
let normalized = serde_json::to_string(&parsed)
|
|
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
|
|
if normalized.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES {
|
|
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
|
|
}
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn normalize_invite_code_metadata_user_tags(
|
|
metadata: &mut Value,
|
|
) -> Result<(), RuntimeProfileFieldError> {
|
|
let Some(object) = metadata.as_object_mut() else {
|
|
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
|
|
};
|
|
// 中文注释:邀请码授予标签复用 metadata,保存时统一收敛成 camelCase 字段。
|
|
let raw = object
|
|
.remove("userTags")
|
|
.or_else(|| object.remove("user_tags"))
|
|
.or_else(|| object.remove("tags"));
|
|
object.remove("user_tags");
|
|
object.remove("tags");
|
|
|
|
let Some(raw) = raw else {
|
|
return Ok(());
|
|
};
|
|
let Value::Array(items) = raw else {
|
|
return Err(RuntimeProfileFieldError::InvalidUserTag);
|
|
};
|
|
let mut raw_tags = Vec::new();
|
|
for item in items {
|
|
let Value::String(value) = item else {
|
|
return Err(RuntimeProfileFieldError::InvalidUserTag);
|
|
};
|
|
raw_tags.push(value);
|
|
}
|
|
let tags = normalize_profile_user_tags(raw_tags)?;
|
|
if !tags.is_empty() {
|
|
object.insert(
|
|
"userTags".to_string(),
|
|
Value::Array(tags.into_iter().map(Value::String).collect()),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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 is_invalid_runtime_profile_code_validity_window(starts_at_micros, expires_at_micros) {
|
|
return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_runtime_profile_redeem_code_validity_window(
|
|
starts_at_micros: Option<i64>,
|
|
expires_at_micros: Option<i64>,
|
|
) -> Result<(), RuntimeProfileFieldError> {
|
|
if is_invalid_runtime_profile_code_validity_window(starts_at_micros, expires_at_micros) {
|
|
return Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_runtime_profile_redeem_code_redeem_time(
|
|
starts_at_micros: Option<i64>,
|
|
expires_at_micros: Option<i64>,
|
|
now_micros: i64,
|
|
) -> Result<(), RuntimeProfileFieldError> {
|
|
if starts_at_micros
|
|
.map(|starts_at| now_micros < starts_at)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(RuntimeProfileFieldError::RedeemCodeNotStarted);
|
|
}
|
|
|
|
if expires_at_micros
|
|
.map(|expires_at| now_micros >= expires_at)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(RuntimeProfileFieldError::RedeemCodeExpired);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn is_invalid_runtime_profile_code_validity_window(
|
|
starts_at_micros: Option<i64>,
|
|
expires_at_micros: Option<i64>,
|
|
) -> bool {
|
|
matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at)
|
|
}
|
|
|
|
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_wallet_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::InvalidWalletMetadata)?;
|
|
if !parsed.is_object() {
|
|
return Err(RuntimeProfileFieldError::InvalidWalletMetadata);
|
|
}
|
|
|
|
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidWalletMetadata)
|
|
}
|
|
|
|
fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFieldError> {
|
|
normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn refund_manual_review_resolution_requires_auditable_fields() {
|
|
let input = build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
|
" refund-1 ".to_string(),
|
|
" admin-1 ".to_string(),
|
|
" 已核对微信商户平台原始账单 ".to_string(),
|
|
" provider_transaction_id_mismatch ".to_string(),
|
|
)
|
|
.expect("manual review input should normalize");
|
|
assert_eq!(input.out_refund_no, "refund-1");
|
|
assert_eq!(input.admin_user_id, "admin-1");
|
|
assert_eq!(input.reason, "已核对微信商户平台原始账单");
|
|
assert_eq!(
|
|
input.expected_error_code,
|
|
"provider_transaction_id_mismatch"
|
|
);
|
|
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
|
"refund-1".to_string(),
|
|
"admin-1".to_string(),
|
|
" ".to_string(),
|
|
"provider_transaction_id_mismatch".to_string(),
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
|
"refund-1".to_string(),
|
|
"admin-1".to_string(),
|
|
"已核对".to_string(),
|
|
"other_error".to_string(),
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invite_code_metadata_tags_alias_is_saved_as_user_tags() {
|
|
let normalized = normalize_invite_code_metadata_json(
|
|
r#"{"tags":["系统"," beta ","系统"],"channel":"spring"}"#.to_string(),
|
|
)
|
|
.expect("metadata tags should normalize");
|
|
|
|
assert_eq!(
|
|
serde_json::from_str::<Value>(&normalized).expect("normalized metadata should parse"),
|
|
serde_json::json!({
|
|
"channel": "spring",
|
|
"userTags": ["系统", "beta"]
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invite_code_metadata_rejects_invalid_tags_alias_shape() {
|
|
assert_eq!(
|
|
normalize_invite_code_metadata_json(r#"{"tags":"北科"}"#.to_string())
|
|
.expect_err("string tags should reject"),
|
|
RuntimeProfileFieldError::InvalidUserTag
|
|
);
|
|
assert_eq!(
|
|
normalize_invite_code_metadata_json(r#"{"tags":[123,"北科"]}"#.to_string())
|
|
.expect_err("mixed tags should reject"),
|
|
RuntimeProfileFieldError::InvalidUserTag
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invite_code_metadata_user_tags_takes_precedence_over_tags_alias() {
|
|
let normalized = normalize_invite_code_metadata_json(
|
|
r#"{"userTags":["正式"],"user_tags":["旧"],"tags":["别名"]}"#.to_string(),
|
|
)
|
|
.expect("metadata userTags should normalize");
|
|
|
|
assert_eq!(
|
|
serde_json::from_str::<Value>(&normalized).expect("normalized metadata should parse"),
|
|
serde_json::json!({
|
|
"userTags": ["正式"]
|
|
})
|
|
);
|
|
}
|
|
}
|