use axum::{ Json, extract::{Extension, Path, Query, State}, http::{HeaderMap, StatusCode}, response::Response, }; use module_runtime::{ AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::runtime::{ ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL, PROFILE_MEMBERSHIP_TIER_SEASON, PROFILE_MEMBERSHIP_TIER_YEAR, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, PROFILE_RECHARGE_PRODUCT_KIND_POINTS, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse, ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, }; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; use crate::{ admin::AuthenticatedAdmin, api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, wechat_pay::{ WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request, current_unix_micros, map_wechat_pay_error, }, }; pub async fn get_profile_dashboard( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_dashboard(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileDashboardSummaryResponse { wallet_balance: record.wallet_balance, total_play_time_ms: record.total_play_time_ms, played_world_count: record.played_world_count, updated_at: record.updated_at, }, )) } pub async fn get_profile_wallet_ledger( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let entries = state .spacetime_client() .list_profile_wallet_ledger(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileWalletLedgerResponse { entries: entries .into_iter() .map(build_profile_wallet_ledger_entry_response) .collect(), }, )) } fn format_profile_wallet_ledger_source_type( source_type: RuntimeProfileWalletLedgerSourceType, ) -> &'static str { match source_type { RuntimeProfileWalletLedgerSourceType::SnapshotSync => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC } RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD } RuntimeProfileWalletLedgerSourceType::InviteInviterReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD } RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD } RuntimeProfileWalletLedgerSourceType::PointsRecharge => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE } RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME } RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND } RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD } RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM } RuntimeProfileWalletLedgerSourceType::DailyTaskReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD } } } pub async fn get_profile_recharge_center( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_recharge_center(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_recharge_center_response(record), )) } pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, headers: HeaderMap, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let payment_channel = normalize_recharge_payment_channel(payload.payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; validate_recharge_device_for_payment_channel(authenticated.claims(), &payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; validate_recharge_payment_channel(&state, &payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; let created_at_micros = current_unix_micros(); let (center, order) = state .spacetime_client() .create_profile_recharge_order( user_id, payload.product_id, payment_channel.clone(), created_at_micros, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; let wechat_mini_program_pay_params = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM { let identity = resolve_wechat_identity_for_payment(&state, &order.user_id) .await .map_err(|error| runtime_profile_error_response(&request_context, error))?; Some( state .wechat_pay_client() .create_mini_program_order(build_wechat_payment_request( order.order_id.clone(), order.product_title.clone(), order.amount_cents, identity, )) .await .map_err(|error| { runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) })?, ) } else { None }; let wechat_h5_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 { Some( state .wechat_pay_client() .create_h5_order(build_wechat_web_payment_request( order.order_id.clone(), order.product_title.clone(), order.amount_cents, resolve_wechat_pay_client_ip(&headers), )) .await .map_err(|error| { runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) })?, ) } else { None }; let wechat_native_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE { Some( state .wechat_pay_client() .create_native_order(build_wechat_web_payment_request( order.order_id.clone(), order.product_title.clone(), order.amount_cents, resolve_wechat_pay_client_ip(&headers), )) .await .map_err(|error| { runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) })?, ) } else { None }; Ok(json_success_body( Some(&request_context), CreateProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), wechat_mini_program_pay_params, wechat_h5_payment, wechat_native_payment, }, )) } pub async fn confirm_wechat_profile_recharge_order( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(order_id): Path, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let (center, order) = state .spacetime_client() .get_profile_recharge_order(order_id.clone()) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; if order.user_id != user_id { return Err(runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), )); } if !is_wechat_recharge_payment_channel(&order.payment_channel) { return Err(runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST) .with_message("该充值订单不是微信支付订单"), )); } if order.status == RuntimeProfileRechargeOrderStatus::Paid { return Ok(json_success_body( Some(&request_context), ConfirmWechatProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), }, )); } if order.status != RuntimeProfileRechargeOrderStatus::Pending { return Ok(json_success_body( Some(&request_context), ConfirmWechatProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), }, )); } let wechat_order = state .wechat_pay_client() .query_order_by_out_trade_no(&order.order_id) .await .map_err(|error| { runtime_profile_error_response(&request_context, map_wechat_pay_error(error)) })?; if wechat_order.out_trade_no != order.order_id { return Err(runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("微信支付查单返回的商户订单号与本地订单不一致") .with_details(json!({ "provider": "wechat_pay" })), )); } if wechat_order.trade_state != "SUCCESS" { return Ok(json_success_body( Some(&request_context), ConfirmWechatProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), }, )); } let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order); let (center, order) = state .spacetime_client() .mark_profile_recharge_order_paid( wechat_order.out_trade_no, paid_at_micros, wechat_order.transaction_id, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ConfirmWechatProfileRechargeOrderResponse { order: build_profile_recharge_order_response(order), center: build_profile_recharge_center_response(center), }, )) } pub async fn submit_profile_feedback( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let evidence_items = payload .evidence_items .into_iter() .map(|item| RuntimeProfileFeedbackEvidenceSnapshot { evidence_id: String::new(), file_name: item.file_name, content_type: item.content_type, size_bytes: item.size_bytes, data_url: item.data_url, }) .collect(); let record = state .spacetime_client() .submit_profile_feedback( user_id, payload.description, payload.contact_phone, evidence_items, created_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SubmitProfileFeedbackResponse { feedback: build_profile_feedback_submission_response(record), }, )) } pub async fn get_profile_referral_invite_center( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_referral_invite_center(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_referral_invite_center_response(record), )) } pub async fn redeem_profile_referral_invite_code( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_redeem_profile_referral_invite_code_response(record), )) } pub async fn redeem_profile_reward_code( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_redeem_profile_reward_code_response(record), )) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AnalyticsMetricQueryParams { pub event_key: String, pub scope_kind: String, pub scope_id: String, pub granularity: String, } pub async fn get_profile_analytics_metric( State(state): State, Extension(request_context): Extension, Extension(_authenticated): Extension, Query(query): Query, ) -> Result, Response> { let scope_kind = parse_tracking_scope_kind(&query.scope_kind).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; let granularity = parse_analytics_granularity(&query.granularity).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; let record = state .spacetime_client() .query_analytics_metric(query.event_key, scope_kind, query.scope_id, granularity) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_analytics_metric_query_response(record), )) } pub async fn get_profile_task_center( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_task_center(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_task_center_response(record), )) } pub async fn claim_profile_task_reward( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(task_id): Path, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .claim_profile_task_reward(user_id, task_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_claim_profile_task_reward_response(record), )) } pub async fn admin_list_profile_task_configs( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, ) -> Result, Response> { let entries = state .spacetime_client() .admin_list_profile_task_configs(admin.session().subject.clone()) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileTaskConfigAdminListResponse { entries: entries .into_iter() .map(build_profile_task_config_admin_response) .collect(), }, )) } pub async fn admin_upsert_profile_task_config( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let cycle = parse_profile_task_cycle(&payload.cycle).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; let scope_kind = parse_tracking_scope_kind(&payload.scope_kind).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; // 中文注释:个人任务配置首版只开放 User scope,HTTP 层先返回清晰错误,领域层再兜底。 if scope_kind != RuntimeTrackingScopeKind::User { return Err(runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST) .with_message("个人任务 scopeKind 首版仅支持 user"), )); } let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .admin_upsert_profile_task_config( admin.session().subject.clone(), payload.task_id, payload.title, payload.description.unwrap_or_default(), payload.event_key, cycle, scope_kind, payload.threshold, payload.reward_points, payload.enabled, payload.sort_order.unwrap_or(10), updated_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_task_config_admin_response(record), )) } pub async fn admin_disable_profile_task_config( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .admin_disable_profile_task_config( admin.session().subject.clone(), payload.task_id, updated_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_task_config_admin_response(record), )) } pub async fn admin_list_profile_recharge_products( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, ) -> Result, Response> { let entries = state .spacetime_client() .admin_list_profile_recharge_products(admin.session().subject.clone()) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileRechargeProductConfigAdminListResponse { entries: entries .into_iter() .map(build_profile_recharge_product_config_admin_response) .collect(), }, )) } pub async fn admin_upsert_profile_recharge_product( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let kind = parse_profile_recharge_product_kind(&payload.kind).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; let tier = parse_profile_membership_tier(&payload.tier).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .admin_upsert_profile_recharge_product( admin.session().subject.clone(), payload.product_id, payload.title, payload.price_cents, kind, payload.points_amount, payload.bonus_points, payload.duration_days, payload.badge_label.unwrap_or_default(), payload.description.unwrap_or_default(), tier, payload.enabled, payload.sort_order.unwrap_or(10), updated_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_recharge_product_config_admin_response(record), )) } pub async fn admin_list_profile_redeem_codes( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, ) -> Result, Response> { let entries = state .spacetime_client() .admin_list_profile_redeem_codes(admin.session().subject.clone()) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileRedeemCodeAdminListResponse { entries: entries .into_iter() .map(build_profile_redeem_code_admin_response) .collect(), }, )) } pub async fn admin_upsert_profile_redeem_code( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| { runtime_profile_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; let record = state .spacetime_client() .admin_upsert_profile_redeem_code( admin.session().subject.clone(), payload.code, mode, payload.reward_points, payload.max_uses, payload.enabled, payload.allowed_user_ids, payload.allowed_public_user_codes, updated_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_redeem_code_admin_response(record), )) } pub async fn admin_disable_profile_redeem_code( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .admin_disable_profile_redeem_code( admin.session().subject.clone(), payload.code, updated_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_redeem_code_admin_response(record), )) } pub async fn admin_list_profile_invite_codes( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, ) -> Result, Response> { let entries = state .spacetime_client() .admin_list_profile_invite_codes(admin.session().subject.clone()) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfileInviteCodeAdminListResponse { entries: entries .into_iter() .map(build_profile_invite_code_admin_response) .collect(), }, )) } pub async fn admin_upsert_profile_invite_code( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let metadata_json = normalize_admin_invite_code_metadata(payload.metadata) .map_err(|error| runtime_profile_error_response(&request_context, error))?; let starts_at_micros = parse_admin_invite_code_time_field("startsAt", payload.starts_at) .map_err(|error| runtime_profile_error_response(&request_context, error))?; let expires_at_micros = parse_admin_invite_code_time_field("expiresAt", payload.expires_at) .map_err(|error| runtime_profile_error_response(&request_context, error))?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() .admin_upsert_profile_invite_code( admin.session().username.clone(), payload.invite_code, metadata_json, starts_at_micros, expires_at_micros, updated_at_micros as i64, ) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), build_profile_invite_code_admin_response(record), )) } pub async fn get_profile_play_stats( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let user_id = authenticated.claims().user_id().to_string(); let record = state .spacetime_client() .get_profile_play_stats(user_id) .await .map_err(|error| { runtime_profile_error_response( &request_context, map_runtime_profile_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), ProfilePlayStatsResponse { total_play_time_ms: record.total_play_time_ms, played_works: record .played_works .into_iter() .map(|entry| ProfilePlayedWorkSummaryResponse { world_key: entry.world_key, owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, world_type: entry.world_type, world_title: entry.world_title, world_subtitle: entry.world_subtitle, first_played_at: entry.first_played_at, last_played_at: entry.last_played_at, last_observed_play_time_ms: entry.last_observed_play_time_ms, }) .collect(), updated_at: record.updated_at, }, )) } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { let (status, provider) = match error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), })) } fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } fn normalize_recharge_payment_channel(raw: Option) -> Result { raw.map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道不能为空") }) } fn validate_recharge_payment_channel( state: &AppState, payment_channel: &str, ) -> Result<(), AppError> { if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK { if is_recharge_mock_channel_allowed(state) { return Ok(()); } return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("生产充值不允许使用 mock 支付渠道")); } if is_wechat_recharge_payment_channel(payment_channel) { validate_real_wechat_recharge_payment_provider(state)?; return Ok(()); } Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效")) } fn validate_recharge_device_for_payment_channel( claims: &platform_auth::AccessTokenClaims, payment_channel: &str, ) -> Result<(), AppError> { if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK { return Ok(()); } if !is_wechat_recharge_payment_channel(payment_channel) { return Ok(()); } let is_supported_device = match payment_channel { PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => { claims.is_wechat_mini_program_device() } PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(), PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(), _ => false, }; if is_supported_device { return Ok(()); } Err(AppError::from_status(StatusCode::FORBIDDEN) .with_message("当前登录设备不支持充值,请在微信环境内登录后重试")) } fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> { if !state.config.wechat_pay_enabled { return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("微信支付真实渠道暂未启用")); } if state .config .wechat_pay_provider .trim() .eq_ignore_ascii_case("real") { return Ok(()); } Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("真实微信支付渠道不能使用 mock 支付配置")) } fn is_recharge_mock_channel_allowed(state: &AppState) -> bool { if cfg!(test) { return state .config .wechat_pay_provider .trim() .eq_ignore_ascii_case(PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK); } false } fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool { matches!( payment_channel, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE ) } fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String { headers .get("x-forwarded-for") .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(',').next()) .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| { headers .get("x-real-ip") .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) }) .unwrap_or("127.0.0.1") .to_string() } async fn resolve_wechat_identity_for_payment( state: &AppState, user_id: &str, ) -> Result { if let Some(identity) = state .wechat_auth_service() .get_identity_by_user_id(user_id) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("读取微信身份失败:{error}")) })? { return Ok(identity.provider_uid); } Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")) } fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 { order .success_time .as_deref() .and_then(|value| parse_rfc3339(value).ok()) .map(offset_datetime_to_unix_micros) .unwrap_or_else(current_unix_micros) } fn build_profile_recharge_center_response( record: RuntimeProfileRechargeCenterRecord, ) -> ProfileRechargeCenterResponse { ProfileRechargeCenterResponse { wallet_balance: record.wallet_balance, membership: ProfileMembershipResponse { status: record.membership.status.as_str().to_string(), tier: record.membership.tier.as_str().to_string(), started_at: record.membership.started_at, expires_at: record.membership.expires_at, updated_at: record.membership.updated_at, }, point_products: record .point_products .into_iter() .map(build_profile_recharge_product_response) .collect(), membership_products: record .membership_products .into_iter() .map(build_profile_recharge_product_response) .collect(), benefits: record .benefits .into_iter() .map(build_profile_membership_benefit_response) .collect(), latest_order: record .latest_order .map(build_profile_recharge_order_response), has_points_recharged: record.has_points_recharged, } } fn build_profile_recharge_product_response( record: RuntimeProfileRechargeProductRecord, ) -> ProfileRechargeProductResponse { ProfileRechargeProductResponse { product_id: record.product_id, title: record.title, price_cents: record.price_cents, kind: record.kind.as_str().to_string(), points_amount: record.points_amount, bonus_points: record.bonus_points, duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, tier: record.tier.as_str().to_string(), } } fn build_profile_membership_benefit_response( record: RuntimeProfileMembershipBenefitRecord, ) -> ProfileMembershipBenefitResponse { ProfileMembershipBenefitResponse { benefit_name: record.benefit_name, normal_value: record.normal_value, month_value: record.month_value, season_value: record.season_value, year_value: record.year_value, } } fn build_profile_recharge_order_response( record: RuntimeProfileRechargeOrderRecord, ) -> ProfileRechargeOrderResponse { ProfileRechargeOrderResponse { order_id: record.order_id, product_id: record.product_id, product_title: record.product_title, kind: record.kind.as_str().to_string(), amount_cents: record.amount_cents, status: record.status.as_str().to_string(), payment_channel: record.payment_channel, paid_at: record.paid_at, provider_transaction_id: record.provider_transaction_id, created_at: record.created_at, points_delta: record.points_delta, membership_expires_at: record.membership_expires_at, } } fn build_profile_feedback_submission_response( record: RuntimeProfileFeedbackSubmissionRecord, ) -> ProfileFeedbackSubmissionResponse { ProfileFeedbackSubmissionResponse { feedback_id: record.feedback_id, status: match record.status { module_runtime::RuntimeProfileFeedbackStatus::Open => { PROFILE_FEEDBACK_STATUS_OPEN.to_string() } }, created_at: record.created_at, evidence_items: record .evidence_items .into_iter() .map(build_profile_feedback_evidence_response) .collect(), } } fn build_profile_feedback_evidence_response( record: RuntimeProfileFeedbackEvidenceRecord, ) -> ProfileFeedbackEvidenceItemResponse { ProfileFeedbackEvidenceItemResponse { evidence_id: record.evidence_id, file_name: record.file_name, content_type: record.content_type, size_bytes: record.size_bytes, } } fn build_profile_referral_invite_center_response( record: RuntimeReferralInviteCenterRecord, ) -> ProfileReferralInviteCenterResponse { ProfileReferralInviteCenterResponse { invite_code: record.invite_code, invite_link_path: record.invite_link_path, invited_count: record.invited_count, rewarded_invite_count: record.rewarded_invite_count, today_inviter_reward_count: record.today_inviter_reward_count, today_inviter_reward_remaining: record.today_inviter_reward_remaining, reward_points: record.reward_points, invited_users: record .invited_users .into_iter() .map(|user| ProfileReferralInvitedUserResponse { user_id: user.user_id, display_name: user.display_name, avatar_url: user.avatar_url, bound_at: user.bound_at, }) .collect(), has_redeemed_code: record.has_redeemed_code, bound_inviter_user_id: record.bound_inviter_user_id, bound_at: record.bound_at, updated_at: record.updated_at, } } fn build_redeem_profile_referral_invite_code_response( record: module_runtime::RuntimeReferralRedeemRecord, ) -> RedeemProfileReferralInviteCodeResponse { RedeemProfileReferralInviteCodeResponse { center: build_profile_referral_invite_center_response(record.center), invitee_reward_granted: record.invitee_reward_granted, inviter_reward_granted: record.inviter_reward_granted, invitee_balance_after: record.invitee_balance_after, inviter_balance_after: record.inviter_balance_after, } } fn build_redeem_profile_reward_code_response( record: RuntimeProfileRewardCodeRedeemRecord, ) -> RedeemProfileRewardCodeResponse { RedeemProfileRewardCodeResponse { wallet_balance: record.wallet_balance, amount_granted: record.amount_granted, ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry), } } fn build_profile_wallet_ledger_entry_response( record: module_runtime::RuntimeProfileWalletLedgerEntryRecord, ) -> ProfileWalletLedgerEntryResponse { ProfileWalletLedgerEntryResponse { id: record.wallet_ledger_id, amount_delta: record.amount_delta, balance_after: record.balance_after, source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(), created_at: record.created_at, } } fn build_profile_task_center_response( record: RuntimeProfileTaskCenterRecord, ) -> ProfileTaskCenterResponse { ProfileTaskCenterResponse { day_key: record.day_key, wallet_balance: record.wallet_balance, tasks: record .tasks .into_iter() .map(build_profile_task_item_response) .collect(), updated_at: record.updated_at, } } fn build_analytics_metric_query_response( record: module_runtime::AnalyticsMetricQueryResponse, ) -> AnalyticsMetricQueryResponse { AnalyticsMetricQueryResponse { buckets: record .buckets .into_iter() .map(|bucket| AnalyticsBucketMetricResponse { bucket_key: bucket.bucket_key, bucket_start_date_key: bucket.bucket_start_date_key, bucket_end_date_key: bucket.bucket_end_date_key, value: bucket.value, }) .collect(), } } fn build_profile_task_item_response( record: RuntimeProfileTaskItemRecord, ) -> ProfileTaskItemResponse { ProfileTaskItemResponse { task_id: record.task_id, title: record.title, description: record.description, event_key: record.event_key, cycle: format_profile_task_cycle(record.cycle).to_string(), threshold: record.threshold, progress_count: record.progress_count, reward_points: record.reward_points, status: format_profile_task_status(record.status).to_string(), day_key: record.day_key, claimed_at: record.claimed_at, updated_at: record.updated_at, } } fn build_claim_profile_task_reward_response( record: RuntimeProfileTaskClaimRecord, ) -> ClaimProfileTaskRewardResponse { ClaimProfileTaskRewardResponse { task_id: record.task_id, day_key: record.day_key, reward_points: record.reward_points, wallet_balance: record.wallet_balance, ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry), center: build_profile_task_center_response(record.center), } } fn build_profile_task_config_admin_response( record: RuntimeProfileTaskConfigRecord, ) -> ProfileTaskConfigAdminResponse { ProfileTaskConfigAdminResponse { task_id: record.task_id, title: record.title, description: record.description, event_key: record.event_key, cycle: format_profile_task_cycle(record.cycle).to_string(), scope_kind: format_tracking_scope_kind(record.scope_kind).to_string(), threshold: record.threshold, reward_points: record.reward_points, enabled: record.enabled, sort_order: record.sort_order, created_by: record.created_by, created_at: record.created_at, updated_by: record.updated_by, updated_at: record.updated_at, } } fn build_profile_recharge_product_config_admin_response( record: RuntimeProfileRechargeProductConfigRecord, ) -> ProfileRechargeProductConfigAdminResponse { ProfileRechargeProductConfigAdminResponse { product_id: record.product_id, title: record.title, price_cents: record.price_cents, kind: format_profile_recharge_product_kind(record.kind).to_string(), points_amount: record.points_amount, bonus_points: record.bonus_points, duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, tier: format_profile_membership_tier(record.tier).to_string(), enabled: record.enabled, sort_order: record.sort_order, created_by: record.created_by, created_at: record.created_at, updated_by: record.updated_by, updated_at: record.updated_at, } } fn normalize_admin_invite_code_metadata(metadata: Option) -> Result { let metadata = match metadata { Some(Value::Null) | None => json!({}), Some(value) if value.is_object() => value, Some(_) => { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("邀请码 metadata 必须是 JSON 对象") .with_details(json!({ "field": "metadata" }))); } }; let metadata_json = serde_json::to_string(&metadata).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message(format!("邀请码 metadata 序列化失败:{error}")) .with_details(json!({ "field": "metadata" })) })?; if metadata_json.len() > 4096 { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("邀请码 metadata 不能超过 4096 bytes") .with_details(json!({ "field": "metadata" }))); } Ok(metadata_json) } fn parse_admin_invite_code_time_field( field: &'static str, value: Option, ) -> Result, AppError> { let Some(value) = value else { return Ok(None); }; let value = value.trim(); if value.is_empty() { return Ok(None); } let parsed = parse_rfc3339(value).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message(format!("邀请码 {field} 必须是 RFC3339 时间字符串")) .with_details(json!({ "field": field, "message": error })) })?; Ok(Some(offset_datetime_to_unix_micros(parsed))) } fn parse_profile_redeem_code_mode(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "public" => Ok(RuntimeProfileRedeemCodeMode::Public), "unique" => Ok(RuntimeProfileRedeemCodeMode::Unique), "private" => Ok(RuntimeProfileRedeemCodeMode::Private), _ => Err("兑换码类型无效".to_string()), } } fn parse_profile_task_cycle(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { PROFILE_TASK_CYCLE_DAILY => Ok(RuntimeProfileTaskCycle::Daily), _ => Err("任务周期无效".to_string()), } } fn parse_profile_recharge_product_kind( raw: &str, ) -> Result { match raw.trim().to_ascii_lowercase().as_str() { PROFILE_RECHARGE_PRODUCT_KIND_POINTS => Ok(RuntimeProfileRechargeProductKind::Points), PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP => { Ok(RuntimeProfileRechargeProductKind::Membership) } _ => Err("充值商品类型无效".to_string()), } } fn parse_profile_membership_tier(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { PROFILE_MEMBERSHIP_TIER_NORMAL => Ok(RuntimeProfileMembershipTier::Normal), PROFILE_MEMBERSHIP_TIER_MONTH => Ok(RuntimeProfileMembershipTier::Month), PROFILE_MEMBERSHIP_TIER_SEASON => Ok(RuntimeProfileMembershipTier::Season), PROFILE_MEMBERSHIP_TIER_YEAR => Ok(RuntimeProfileMembershipTier::Year), _ => Err("会员档位无效".to_string()), } } fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), TRACKING_SCOPE_KIND_WORK => Ok(RuntimeTrackingScopeKind::Work), TRACKING_SCOPE_KIND_MODULE => Ok(RuntimeTrackingScopeKind::Module), TRACKING_SCOPE_KIND_USER => Ok(RuntimeTrackingScopeKind::User), _ => Err("埋点范围无效".to_string()), } } fn parse_analytics_granularity(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { ANALYTICS_GRANULARITY_DAY => Ok(AnalyticsGranularity::Day), ANALYTICS_GRANULARITY_WEEK => Ok(AnalyticsGranularity::Week), ANALYTICS_GRANULARITY_MONTH => Ok(AnalyticsGranularity::Month), ANALYTICS_GRANULARITY_QUARTER => Ok(AnalyticsGranularity::Quarter), ANALYTICS_GRANULARITY_YEAR => Ok(AnalyticsGranularity::Year), _ => Err("统计粒度无效".to_string()), } } fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str { match cycle { RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY, } } fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str { match status { RuntimeProfileTaskStatus::Incomplete => PROFILE_TASK_STATUS_INCOMPLETE, RuntimeProfileTaskStatus::Claimable => PROFILE_TASK_STATUS_CLAIMABLE, RuntimeProfileTaskStatus::Claimed => PROFILE_TASK_STATUS_CLAIMED, RuntimeProfileTaskStatus::Disabled => PROFILE_TASK_STATUS_DISABLED, } } fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) -> &'static str { match kind { RuntimeProfileRechargeProductKind::Points => PROFILE_RECHARGE_PRODUCT_KIND_POINTS, RuntimeProfileRechargeProductKind::Membership => PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, } } fn format_profile_membership_tier(tier: RuntimeProfileMembershipTier) -> &'static str { match tier { RuntimeProfileMembershipTier::Normal => PROFILE_MEMBERSHIP_TIER_NORMAL, RuntimeProfileMembershipTier::Month => PROFILE_MEMBERSHIP_TIER_MONTH, RuntimeProfileMembershipTier::Season => PROFILE_MEMBERSHIP_TIER_SEASON, RuntimeProfileMembershipTier::Year => PROFILE_MEMBERSHIP_TIER_YEAR, } } fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str { match scope_kind { RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE, RuntimeTrackingScopeKind::Work => TRACKING_SCOPE_KIND_WORK, RuntimeTrackingScopeKind::Module => TRACKING_SCOPE_KIND_MODULE, RuntimeTrackingScopeKind::User => TRACKING_SCOPE_KIND_USER, } } fn build_profile_invite_code_admin_response( record: RuntimeProfileInviteCodeRecord, ) -> ProfileInviteCodeAdminResponse { let metadata = serde_json::from_str::(&record.metadata_json).unwrap_or_else(|_| json!({})); ProfileInviteCodeAdminResponse { user_id: record.user_id, invite_code: record.invite_code, metadata, starts_at: record.starts_at, expires_at: record.expires_at, status: record.status.as_str().to_string(), created_at: record.created_at, updated_at: record.updated_at, } } fn build_profile_redeem_code_admin_response( record: RuntimeProfileRedeemCodeRecord, ) -> ProfileRedeemCodeAdminResponse { ProfileRedeemCodeAdminResponse { code: record.code, mode: record.mode.as_str().to_string(), reward_points: record.reward_points, max_uses: record.max_uses, global_used_count: record.global_used_count, enabled: record.enabled, allowed_user_ids: record.allowed_user_ids, created_by: record.created_by, created_at: record.created_at, updated_at: record.updated_at, } } #[cfg(test)] mod tests { use module_runtime::RuntimeProfileWalletLedgerSourceType; use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata}; use axum::{ body::Body, http::{Request, StatusCode}, }; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::Value; use std::time::Duration; use time::OffsetDateTime; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] fn profile_wallet_ledger_source_type_formats_backend_values() { assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD ); assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetOperationConsume ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME ); assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetOperationRefund ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND ); assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM ); assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::DailyTaskReward ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD ); } #[tokio::test] async fn profile_dashboard_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/profile/dashboard") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let confirm_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_wallet_ledger_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/profile/wallet-ledger") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_tasks_require_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let list_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/profile/tasks") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); let claim_response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/tasks/daily_login/claim") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED); assert_eq!(claim_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_play_stats_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/profile/play-stats") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_recharge_center_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/profile/recharge-center") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_recharge_order_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("content-type", "application/json") .body(Body::from(r#"{"productId":"points_60"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_recharge_order_rejects_missing_payment_channel_before_spacetime() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(r#"{"productId":"points_60"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["error"]["message"], "充值支付渠道不能为空"); } #[tokio::test] async fn profile_recharge_order_rejects_unknown_payment_channel_before_spacetime() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( r#"{"productId":"points_60","paymentChannel":"card"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["error"]["message"], "充值支付渠道无效"); } #[tokio::test] async fn profile_recharge_order_rejects_mock_when_pay_provider_is_real() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_provider: "real".to_string(), spacetime_procedure_timeout: Duration::from_secs(1), ..AppConfig::default() }) .await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( r#"{"productId":"points_60","paymentChannel":"mock"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["error"]["message"], "生产充值不允许使用 mock 支付渠道" ); } #[tokio::test] async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_enabled: true, wechat_pay_provider: "mock".to_string(), spacetime_procedure_timeout: Duration::from_secs(1), ..AppConfig::default() }) .await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::FORBIDDEN); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["error"]["message"], "当前登录设备不支持充值,请在微信环境内登录后重试" ); } #[tokio::test] async fn profile_recharge_order_rejects_mismatched_wechat_channel_before_spacetime() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_enabled: true, wechat_pay_provider: "mock".to_string(), spacetime_procedure_timeout: Duration::from_secs(1), ..AppConfig::default() }) .await; let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5"); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::FORBIDDEN); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["error"]["message"], "当前登录设备不支持充值,请在微信环境内登录后重试" ); } #[tokio::test] async fn profile_recharge_order_allows_desktop_wechat_native_channel_before_provider_check() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_enabled: true, wechat_pay_provider: "mock".to_string(), spacetime_procedure_timeout: Duration::from_secs(1), ..AppConfig::default() }) .await; let token = issue_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat"); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["error"]["message"], "真实微信支付渠道不能使用 mock 支付配置" ); } #[tokio::test] async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() { let state = seed_authenticated_state_with_config(AppConfig { wechat_pay_enabled: true, wechat_pay_provider: "mock".to_string(), spacetime_procedure_timeout: Duration::from_secs(1), ..AppConfig::default() }) .await; let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5"); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/recharge/orders") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["error"]["message"], "真实微信支付渠道不能使用 mock 支付配置" ); } #[tokio::test] async fn profile_feedback_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/feedback") .header("content-type", "application/json") .body(Body::from( r#"{"description":"反馈页面上传图片后没有显示预览"}"#, )) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_referral_invite_center_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/profile/referrals/invite-center") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_referral_redeem_code_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/referrals/redeem-code") .header("content-type", "application/json") .body(Body::from(r#"{"inviteCode":"SY12345678"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/profile/referrals/redeem-code") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from(r#"{"inviteCode":"SY12345678"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( payload["error"]["details"]["provider"], Value::String("spacetimedb".to_string()) ); } #[test] fn admin_invite_code_metadata_accepts_only_json_object() { assert_eq!( normalize_admin_invite_code_metadata(None).expect("empty metadata should default"), "{}" ); assert_eq!( normalize_admin_invite_code_metadata(Some(serde_json::json!({ "channel": "spring", "source": "banner" }))) .expect("object metadata should serialize"), r#"{"channel":"spring","source":"banner"}"# ); let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring"))) .expect_err("non-object metadata should reject"); assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象"); } #[tokio::test] async fn runtime_profile_legacy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); for uri in [ "/api/runtime/profile/dashboard", "/api/runtime/profile/wallet-ledger", "/api/runtime/profile/recharge-center", "/api/runtime/profile/recharge/orders", "/api/runtime/profile/feedback", "/api/runtime/profile/referrals/invite-center", "/api/runtime/profile/referrals/redeem-code", "/api/runtime/profile/redeem-codes/redeem", "/api/runtime/profile/play-stats", "/api/runtime/profile/save-archives", "/api/runtime/profile/save-archives/world-1", "/api/runtime/profile/browse-history", ] { let response = app .clone() .oneshot( Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); } } #[tokio::test] async fn admin_profile_task_routes_require_admin_authentication() { let app = build_router(AppState::new(admin_enabled_test_config()).expect("state should build")); let list_response = app .clone() .oneshot( Request::builder() .method("GET") .uri("/admin/api/profile/tasks") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); let upsert_response = app .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/api/profile/tasks") .header("content-type", "application/json") .body(Body::from(r#"{"taskId":"daily_login"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); let disable_response = app .oneshot( Request::builder() .method("POST") .uri("/admin/api/profile/tasks/disable") .header("content-type", "application/json") .body(Body::from(r#"{"taskId":"daily_login"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED); assert_eq!(upsert_response.status(), StatusCode::UNAUTHORIZED); assert_eq!(disable_response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn admin_profile_code_list_routes_require_admin_authentication() { let app = build_router(AppState::new(admin_enabled_test_config()).expect("state should build")); for uri in [ "/admin/api/profile/redeem-codes", "/admin/api/profile/invite-codes", "/admin/api/profile/recharge-products", ] { let response = app .clone() .oneshot( Request::builder() .method("GET") .uri(uri) .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); } } async fn seed_authenticated_state() -> AppState { seed_authenticated_state_with_config(fast_spacetime_timeout_config()).await } async fn seed_authenticated_state_with_config(config: AppConfig) -> AppState { let state = AppState::new(config).expect("state should build"); state .seed_test_phone_user_with_password("13800138104", "secret123") .await .id; state } fn fast_spacetime_timeout_config() -> AppConfig { AppConfig { spacetime_procedure_timeout: Duration::from_secs(1), ..AppConfig::default() } } fn admin_enabled_test_config() -> AppConfig { AppConfig { admin_username: Some("root".to_string()), admin_password: Some("secret123".to_string()), ..AppConfig::default() } } fn issue_access_token(state: &AppState) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: state .seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("资料页用户".to_string()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } fn issue_wechat_h5_access_token( state: &AppState, client_platform: &str, session_id: &str, ) -> String { let claims = AccessTokenClaims::from_input_with_device( AccessTokenClaimsInput { user_id: "user_00000001".to_string(), session_id: state .seed_test_refresh_session_for_user_id("user_00000001", session_id), provider: AuthProvider::Wechat, roles: vec!["user".to_string()], token_version: 2, phone_verified: true, binding_status: BindingStatus::Active, display_name: Some("微信资料页用户".to_string()), }, Some(platform_auth::AccessTokenDeviceInfo { client_type: "wechat_h5".to_string(), client_runtime: "wechat_embedded_browser".to_string(), client_platform: client_platform.to_string(), }), state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") } }