Files
Genarrative/server-rs/crates/module-runtime/src/commands.rs
T
kdletters a597fcd076 收紧退款人工复核与重试契约
补齐退款事实对比字段与人工复核审计展示
人工复核重放严格校验管理员、原因和错误码
退款占用重试完整匹配管理员与归一化原因
统一退款原因持久化上限并修复刷新恢复
补充前后端回归测试、生成绑定与数据契约文档
2026-07-14 19:20:25 +08:00

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(&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 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": ["正式"]
})
);
}
}