use std::collections::{BTreeMap, BTreeSet}; use axum::{ Json, extract::{Extension, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; use module_auth::AuthUser; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileAdminWalletSnapshot, RuntimeProfileRechargeOrderAdminEntrySnapshot, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot, RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, RuntimeProfileRechargeRefundRecoveryStatus, RuntimeProfileRechargeRefundSnapshot, RuntimeProfileRechargeRefundStatus, build_runtime_profile_admin_wallet_get_input, build_runtime_profile_recharge_order_admin_list_input, build_runtime_profile_recharge_refund_hold_prepare_input, build_runtime_profile_recharge_refund_hold_preview_input, build_runtime_profile_recharge_refund_manual_review_resolve_input, build_runtime_profile_recharge_refund_settlement_plan, build_runtime_profile_wallet_consumption_projection_initialize_input, build_runtime_profile_wallet_consumption_reconcile_input, build_runtime_profile_wallet_manual_restriction_upsert_input, }; use platform_wechat::pay::{ WechatPayError, WechatPayNotifyOrder, WechatPayRefund, WechatPayRefundRequest, }; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_contracts::admin::{ ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload, AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload, AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest, AdminUserConsumptionProjectionInitializeResponse, AdminUserConsumptionReconcileRequest, AdminUserConsumptionReconcileResponse, AdminUserDetailQuery, AdminUserDetailResponse, AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest, AdminWalletRestrictionResponse, AdminWechatPaymentCheckPayload, }; use spacetime_client::SpacetimeClientError; use crate::{ admin::{ AdminDisplayNameDirectory, AuthenticatedAdmin, fetch_admin_user_cumulative_recharged_cents, load_admin_display_name_directory, }, api_response::json_success_body, http_error::AppError, request_context::RequestContext, state::AppState, wechat::pay::{build_wechat_pay_refund_observation, current_unix_micros, map_wechat_pay_error}, }; const DEFAULT_ORDER_LIMIT: u32 = 100; const USER_DETAIL_ORDER_LIMIT: u32 = 20; const WECHAT_PAYMENT_NOTIFY_PATH: &str = "/api/profile/recharge/wechat/notify"; const WECHAT_REFUND_NOTIFY_PATH: &str = "/api/profile/recharge/wechat/refund-notify"; pub async fn admin_list_recharge_orders( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Query(query): Query, ) -> Result, Response> { refresh_auth_projection(&state, &request_context).await?; let user_id = resolve_optional_user_id(&state, query.user_id, query.public_user_code) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_recharge_order_admin_list_input( query.order_id, user_id, query.provider_transaction_id, query.payment_channel, parse_order_status(query.status.as_deref()) .map_err(|error| error_response(&request_context, error))?, parse_optional_time_micros(query.created_after.as_deref(), "createdAfter") .map_err(|error| error_response(&request_context, error))?, parse_optional_time_micros(query.created_before.as_deref(), "createdBefore") .map_err(|error| error_response(&request_context, error))?, query.limit.unwrap_or(DEFAULT_ORDER_LIMIT), ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let entries = state .spacetime_client() .admin_list_profile_recharge_orders(input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let user_summaries = load_user_summaries(&state, &entries); Ok(json_success_body( Some(&request_context), AdminRechargeOrderListResponse { entries: entries .into_iter() .map(|entry| { let user = user_summaries.get(&entry.order.user_id).cloned(); map_order_entry(entry, user) }) .collect(), }, )) } pub async fn admin_get_user_detail( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Query(query): Query, ) -> Result, Response> { refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, query.user_id, query.public_user_code) .map_err(|error| error_response(&request_context, error))?; let wallet_detail = state .spacetime_client() .admin_get_profile_wallet_detail( build_runtime_profile_admin_wallet_get_input(user.id.clone()).map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?, ) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let list_input = build_runtime_profile_recharge_order_admin_list_input( None, Some(user.id.clone()), None, None, None, None, None, USER_DETAIL_ORDER_LIMIT, ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let orders = state .spacetime_client() .admin_list_profile_recharge_orders(list_input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let summary = map_user_summary(&user); let admin_display_names = load_admin_display_name_directory(&state) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let cumulative_recharged_cents = fetch_admin_user_cumulative_recharged_cents(&state, &user.id).await; Ok(json_success_body( Some(&request_context), AdminUserDetailResponse { user_id: user.id, public_user_code: user.public_user_code, display_name: user.display_name, avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked.clone(), login_method: user.login_method.as_str().to_string(), binding_status: user.binding_status.as_str().to_string(), phone_bound: user.phone_number_masked.is_some(), wechat_bound: user.wechat_bound, historical_consumed_points: wallet_detail.historical_consumed_points, cumulative_recharged_cents, can_reconcile_consumption: admin.can(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE), wallet: map_wallet(wallet_detail.wallet, Some(&admin_display_names)), recharge_orders: orders .into_iter() .map(|entry| map_order_entry(entry, Some(summary.clone()))) .collect(), }, )) } pub async fn admin_reconcile_user_consumption( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, Some(payload.user_id), None) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_wallet_consumption_reconcile_input( user.id, admin.session().subject.clone(), ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let record = state .spacetime_client() .admin_reconcile_profile_wallet_consumption(input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), AdminUserConsumptionReconcileResponse { user_id: record.user_id, previous_historical_consumed_points: record.previous_historical_consumed_points, historical_consumed_points: record.historical_consumed_points, changed: record.changed, reconciled_at_micros: record.reconciled_at_micros, }, )) } pub async fn admin_initialize_user_consumption_projections( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, ) -> Result, Response> { let input = build_runtime_profile_wallet_consumption_projection_initialize_input( admin.session().subject.clone(), ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let record = state .spacetime_client() .admin_initialize_profile_wallet_consumption_projections(input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), AdminUserConsumptionProjectionInitializeResponse { scanned_ledger_count: record.scanned_ledger_count, projected_user_count: record.projected_user_count, inserted_projection_count: record.inserted_projection_count, updated_projection_count: record.updated_projection_count, initialized_at_micros: record.initialized_at_micros, }, )) } pub async fn admin_preview_recharge_refund( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, Response> { let (entry, payment_check) = reconcile_and_validate_order( &state, &request_context, payload.order_id, payload.refund_amount_cents, ) .await?; let user = load_user_summary(&state, &entry.order.user_id); let remaining_refundable_cents = remaining_refundable_cents(&entry); let preview_input = build_runtime_profile_recharge_refund_hold_preview_input( entry.order.order_id.clone(), payload.refund_amount_cents, ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let preview = state .spacetime_client() .preview_profile_recharge_refund_hold(preview_input) .await; let settlement = entry.settlement.as_ref(); let recovery_plan = build_runtime_profile_recharge_refund_settlement_plan( settlement .map(|value| value.successful_refund_count) .unwrap_or(0), settlement .map(|value| value.cumulative_success_refund_cents) .unwrap_or(0), settlement .map(|value| value.target_recovery_points) .unwrap_or(0), payload.refund_amount_cents, entry.order.amount_cents, entry.order.points_delta, ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::CONFLICT).with_message(message), ) })?; let (incremental_recovery_points, hold_ready, hold_block_reason_code) = match preview { Ok(_) => (recovery_plan.incremental_target_recovery_points, true, None), Err(error) => ( 0, false, Some(classify_refund_block_reason(&error.to_string()).to_string()), ), }; let can_submit = payment_check.verified && hold_ready; let block_reason_code = if payment_check.verified { hold_block_reason_code } else { Some(payment_check_block_reason_code(&payment_check.trade_state).to_string()) }; let mapped_entry = map_order_entry(entry, user); Ok(json_success_body( Some(&request_context), AdminRechargeRefundPreviewResponse { order: mapped_entry, payment_check, refund_amount_cents: payload.refund_amount_cents, incremental_recovery_points, remaining_refundable_cents, can_submit, block_reason_code, }, )) } pub async fn admin_execute_recharge_refund( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result { let request_id = normalize_request_id(&payload.request_id) .map_err(|error| error_response(&request_context, error))?; let out_refund_no = build_out_refund_no(&payload.order_id, request_id); let refund_reason = normalize_refund_reason(payload.reason.as_deref()); let admin_user_id = admin.session().subject.clone(); let mut entry = load_order(&state, &request_context, &payload.order_id).await?; validate_refund_provider_route(&entry) .map_err(|error| error_response(&request_context, error))?; let has_matching_hold = entry.active_hold.as_ref().is_some_and(|hold| { refund_hold_matches_execute_request( hold, &out_refund_no, payload.refund_amount_cents, &admin_user_id, &refund_reason, ) }); if entry.active_hold.is_some() && !has_matching_hold { return Err(error_response( &request_context, AppError::from_status(StatusCode::CONFLICT) .with_message("该订单已有另一笔退款处理中") .with_details(json!({"reasonCode": "refund_in_progress"})), )); } let mut payment_check = query_and_validate_payment(&state, &entry) .await .map_err(|error| error_response(&request_context, error))?; if !has_matching_hold { refresh_known_refunds(&state, &entry) .await .map_err(|error| error_response(&request_context, error))?; entry = load_order(&state, &request_context, &payload.order_id).await?; payment_check.verified = payment_trade_state_allows_additional_refund( &payment_check.trade_state, cumulative_success_refund_cents(&entry), entry.order.amount_cents, ); validate_refund_amount(&entry, payload.refund_amount_cents) .map_err(|error| error_response(&request_context, error))?; } let stable_hold_retry = has_matching_hold && payment_check .trade_state .trim() .eq_ignore_ascii_case("REFUND"); if !payment_check.verified && !stable_hold_retry { let reason_code = payment_check_block_reason_code(&payment_check.trade_state); return Err(error_response( &request_context, AppError::from_status(StatusCode::CONFLICT) .with_message(payment_check_block_message(reason_code)) .with_details(json!({"reasonCode": reason_code})), )); } if !has_matching_hold { let prepare_input = build_runtime_profile_recharge_refund_hold_prepare_input( entry.order.order_id.clone(), out_refund_no.clone(), payload.refund_amount_cents, admin_user_id, refund_reason.clone(), ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let prepared_hold = state .spacetime_client() .prepare_profile_recharge_refund_hold(prepare_input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; if let Err(reason_code) = validate_prepared_refund_hold_status(prepared_hold.status) { return Err(error_response( &request_context, AppError::from_status(StatusCode::CONFLICT) .with_message(refund_block_message(reason_code)) .with_details(json!({"reasonCode": reason_code})), )); } } let notify_url = build_refund_notify_url(&state).map_err(|error| error_response(&request_context, error))?; let transaction_id = entry.order.provider_transaction_id.clone().ok_or_else(|| { error_response( &request_context, AppError::from_status(StatusCode::CONFLICT).with_message("充值订单缺少微信支付单号"), ) })?; let provider_result = state .wechat_pay_client() .create_refund(WechatPayRefundRequest { transaction_id, out_trade_no: entry.order.order_id.clone(), out_refund_no: out_refund_no.clone(), reason: Some(refund_reason), notify_url, refund_amount_cents: payload.refund_amount_cents, total_amount_cents: entry.order.amount_cents, }) .await; let provider_refund = match provider_result { Ok(refund) => Some(( refund, RuntimeProfileRechargeRefundObservationSource::ApiRequest, )), Err(create_error) => match state .wechat_pay_client() .query_refund_by_out_refund_no(&out_refund_no) .await { Ok(refund) => Some((refund, RuntimeProfileRechargeRefundObservationSource::Query)), Err(_) => { let entry = load_order(&state, &request_context, &entry.order.order_id).await?; let user = load_user_summary(&state, &entry.order.user_id); let response = AdminRechargeRefundActionResponse { out_refund_no, provider_status: "unknown".to_string(), result_code: "provider_status_unknown".to_string(), provider_status_unknown: true, order: map_order_entry(entry, user), }; tracing::warn!( error_code = create_error.diagnostic_code(), "后台退款请求结果未知,保留钱包占用等待对账" ); return Ok(( StatusCode::ACCEPTED, json_success_body(Some(&request_context), response), ) .into_response()); } }, }; let (refund, observation_source) = provider_refund.expect("provider refund must exist after matched branches"); persist_refund_fact(&state, &refund, observation_source) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let entry = load_order(&state, &request_context, &refund.out_trade_no).await?; let user = load_user_summary(&state, &entry.order.user_id); let response = build_refund_action_response(refund, entry, user); Ok(( StatusCode::OK, json_success_body(Some(&request_context), response), ) .into_response()) } pub async fn admin_register_recharge_refund( State(state): State, Extension(request_context): Extension, Extension(_admin): Extension, Json(payload): Json, ) -> Result, Response> { let out_refund_no = payload.out_refund_no.trim(); if out_refund_no.is_empty() || out_refund_no.len() > 64 { return Err(error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST) .with_message("商户退款单号 out_refund_no 格式无效"), )); } let refund = state .wechat_pay_client() .query_refund_by_out_refund_no(out_refund_no) .await .map_err(|error| { let error = match error { WechatPayError::OrderNotExist(_) if is_likely_wechat_refund_id(out_refund_no) => { AppError::from_status(StatusCode::BAD_REQUEST) .with_message( "该编号疑似微信退款单号 refund_id,请在商户平台退款详情复制商户退款单号 out_refund_no", ) .with_details(json!({"reasonCode": "provider_refund_id_not_supported"})) } WechatPayError::OrderNotExist(_) => AppError::from_status(StatusCode::NOT_FOUND) .with_message("未找到该商户退款单号 out_refund_no") .with_details(json!({"reasonCode": "out_refund_no_not_found"})), other => map_wechat_pay_error(other), }; error_response(&request_context, error) })?; persist_refund_fact( &state, &refund, RuntimeProfileRechargeRefundObservationSource::Query, ) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let entry = load_order(&state, &request_context, &refund.out_trade_no).await?; let user = load_user_summary(&state, &entry.order.user_id); Ok(json_success_body( Some(&request_context), build_refund_action_response(refund, entry, user), )) } pub async fn admin_resolve_recharge_refund_manual_review( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { let input = build_runtime_profile_recharge_refund_manual_review_resolve_input( payload.out_refund_no, admin.session().subject.clone(), payload.reason, payload.expected_error_code, ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let (refund, _, _, resolution_code) = state .spacetime_client() .resolve_profile_recharge_refund_manual_review(input) .await .map_err(|error| { error_response( &request_context, AppError::from_status(StatusCode::CONFLICT) .with_message("人工确认退款归属失败") .with_details(json!({"message": error.to_string()})), ) })?; let entry = load_order(&state, &request_context, &refund.order_id).await?; let user = load_user_summary(&state, &entry.order.user_id); Ok(json_success_body( Some(&request_context), AdminRechargeRefundActionResponse { out_refund_no: refund.out_refund_no, provider_status: refund.provider_status.as_str().to_string(), result_code: resolution_code, provider_status_unknown: false, order: map_order_entry(entry, user), }, )) } pub async fn admin_update_wallet_restriction( State(state): State, Extension(request_context): Extension, Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, Some(payload.user_id), None) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_wallet_manual_restriction_upsert_input( user.id, payload.frozen, payload.reason, admin.session().subject.clone(), ) .map_err(|message| { error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let admin_display_names = load_admin_display_name_directory(&state) .await .map_err(|error| spacetime_error_response(&request_context, error))?; let wallet = state .spacetime_client() .admin_upsert_profile_wallet_manual_restriction(input) .await .map_err(|error| spacetime_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), AdminWalletRestrictionResponse { wallet: map_wallet(wallet, Some(&admin_display_names)), }, )) } async fn reconcile_and_validate_order( state: &AppState, request_context: &RequestContext, order_id: String, refund_amount_cents: u64, ) -> Result< ( RuntimeProfileRechargeOrderAdminEntrySnapshot, AdminWechatPaymentCheckPayload, ), Response, > { let mut entry = load_order(state, request_context, &order_id).await?; validate_refund_provider_route(&entry) .map_err(|error| error_response(request_context, error))?; let mut payment_check = query_and_validate_payment(state, &entry) .await .map_err(|error| error_response(request_context, error))?; let known_refunds_refreshed = refresh_known_refunds(state, &entry) .await .map_err(|error| error_response(request_context, error))?; entry = load_order(state, request_context, &order_id).await?; payment_check.verified = payment_trade_state_allows_additional_refund( &payment_check.trade_state, cumulative_success_refund_cents(&entry), entry.order.amount_cents, ); validate_refund_amount(&entry, refund_amount_cents) .map_err(|error| error_response(request_context, error))?; payment_check.known_refunds_refreshed = known_refunds_refreshed; Ok((entry, payment_check)) } async fn load_order( state: &AppState, request_context: &RequestContext, order_id: &str, ) -> Result { let input = build_runtime_profile_recharge_order_admin_list_input( Some(order_id.to_string()), None, None, None, None, None, None, 2, ) .map_err(|message| { error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), ) })?; let mut entries = state .spacetime_client() .admin_list_profile_recharge_orders(input) .await .map_err(|error| spacetime_error_response(request_context, error))?; if entries.len() != 1 { return Err(error_response( request_context, AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), )); } Ok(entries.remove(0)) } async fn query_and_validate_payment( state: &AppState, entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, ) -> Result { let queried = state .wechat_pay_client() .query_order_by_out_trade_no(&entry.order.order_id) .await .map_err(map_wechat_pay_error)?; validate_queried_payment(&entry.order, &queried)?; let cumulative_refund_cents = cumulative_success_refund_cents(entry); let verified = payment_trade_state_allows_additional_refund( &queried.trade_state, cumulative_refund_cents, entry.order.amount_cents, ); tracing::debug!( trade_state = queried.trade_state.as_str(), cumulative_refund_cents, order_total_cents = entry.order.amount_cents, verified, "后台退款微信支付订单核验完成" ); Ok(AdminWechatPaymentCheckPayload { verified, trade_state: queried.trade_state, transaction_id: queried.transaction_id, amount_total_cents: queried.amount_total_cents, known_refunds_refreshed: 0, }) } fn validate_queried_payment( order: &module_runtime::RuntimeProfileRechargeOrderSnapshot, queried: &WechatPayNotifyOrder, ) -> Result<(), AppError> { if queried.out_trade_no != order.order_id { return Err(AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("微信支付查单返回了不匹配的商户订单号")); } if queried.amount_total_cents != Some(order.amount_cents) { return Err(AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("微信支付查单金额与本地充值订单不一致")); } if let Some(expected) = order.provider_transaction_id.as_deref() && queried.transaction_id.as_deref() != Some(expected) { return Err(AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("微信支付查单交易号与本地充值订单不一致")); } Ok(()) } async fn refresh_known_refunds( state: &AppState, entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, ) -> Result { let mut out_refund_nos = entry .refunds .iter() .map(|refund| refund.out_refund_no.clone()) .collect::>(); if let Some(hold) = entry.active_hold.as_ref() { out_refund_nos.insert(hold.out_refund_no.clone()); } let mut refreshed = 0_u32; for out_refund_no in out_refund_nos { let refund = state .wechat_pay_client() .query_refund_by_out_refund_no(&out_refund_no) .await .map_err(map_wechat_pay_error)?; persist_refund_fact( state, &refund, RuntimeProfileRechargeRefundObservationSource::Query, ) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("刷新已知微信退款单失败") .with_details(json!({"provider": "spacetimedb", "message": error.to_string()})) })?; refreshed = refreshed.saturating_add(1); } Ok(refreshed) } async fn persist_refund_fact( state: &AppState, refund: &WechatPayRefund, source: RuntimeProfileRechargeRefundObservationSource, ) -> Result<(), SpacetimeClientError> { let fingerprint = refund_fact_fingerprint(refund); let observation = build_wechat_pay_refund_observation( admin_refund_observation_id(source, &fingerprint), source, None, fingerprint, refund, current_unix_micros(), ) .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; state .spacetime_client() .record_profile_recharge_refund_observation(observation) .await?; Ok(()) } fn validate_refund_amount( entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, refund_amount_cents: u64, ) -> Result<(), AppError> { let reason_code = order_refund_block_reason(entry); if let Some(reason_code) = reason_code { return Err(AppError::from_status(StatusCode::CONFLICT) .with_message(refund_block_message(reason_code)) .with_details(json!({"reasonCode": reason_code}))); } let remaining = remaining_refundable_cents(entry); if refund_amount_cents == 0 || refund_amount_cents > remaining { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("退款金额必须大于 0 且不超过订单剩余可退金额") .with_details(json!({"reasonCode": "invalid_refund_amount", "remainingRefundableCents": remaining}))); } Ok(()) } fn order_refund_block_reason( entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, ) -> Option<&'static str> { if let Some(reason_code) = refund_provider_route_block_reason(entry.order.kind, &entry.order.payment_channel) { return Some(reason_code); } if !matches!( entry.order.status, RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded ) { return Some("order_not_paid"); } if entry.order.provider_transaction_id.is_none() { return Some("provider_transaction_missing"); } if entry.refunds.iter().any(|refund| { matches!( refund.provider_status, RuntimeProfileRechargeRefundStatus::Processing | RuntimeProfileRechargeRefundStatus::Abnormal ) }) { return Some("refund_in_progress"); } if remaining_refundable_cents(entry) == 0 { return Some("fully_refunded"); } if entry.active_hold.is_some() { return Some("refund_in_progress"); } None } fn remaining_refundable_cents(entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot) -> u64 { entry .order .amount_cents .saturating_sub(cumulative_success_refund_cents(entry)) } fn cumulative_success_refund_cents(entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot) -> u64 { entry .settlement .as_ref() .map(|value| value.cumulative_success_refund_cents) .unwrap_or(0) } fn payment_trade_state_allows_additional_refund( trade_state: &str, cumulative_success_refund_cents: u64, order_total_cents: u64, ) -> bool { match trade_state.trim().to_ascii_uppercase().as_str() { "SUCCESS" => true, "REFUND" => { cumulative_success_refund_cents > 0 && cumulative_success_refund_cents < order_total_cents } _ => false, } } fn payment_check_block_reason_code(trade_state: &str) -> &'static str { if trade_state.trim().eq_ignore_ascii_case("REFUND") { "wechat_refund_not_reconciled" } else { "wechat_order_not_paid" } } fn payment_check_block_message(reason_code: &str) -> &'static str { match reason_code { "wechat_refund_not_reconciled" => "微信支付订单存在尚未登记或未完成对账的退款", _ => "微信支付订单尚未确认可退款", } } fn build_refund_action_response( refund: WechatPayRefund, entry: RuntimeProfileRechargeOrderAdminEntrySnapshot, user: Option, ) -> AdminRechargeRefundActionResponse { let result_code = if refund.status.eq_ignore_ascii_case("SUCCESS") { match entry.settlement.as_ref().map(|value| value.recovery_status) { Some(RuntimeProfileRechargeRefundRecoveryStatus::Shortfall) => "refund_debt", Some(RuntimeProfileRechargeRefundRecoveryStatus::ManualReview) => "manual_review", Some(RuntimeProfileRechargeRefundRecoveryStatus::Applied) => "reconciled", _ => "pending_reconciliation", } } else if refund.status.eq_ignore_ascii_case("PROCESSING") { "processing" } else if refund.status.eq_ignore_ascii_case("CLOSED") { "closed" } else { "abnormal" }; AdminRechargeRefundActionResponse { out_refund_no: refund.out_refund_no, provider_status: refund.status.to_ascii_lowercase(), result_code: result_code.to_string(), provider_status_unknown: false, order: map_order_entry(entry, user), } } fn map_order_entry( entry: RuntimeProfileRechargeOrderAdminEntrySnapshot, user: Option, ) -> AdminRechargeOrderEntryPayload { let settlement = entry.settlement.as_ref(); let remaining_refundable_cents = remaining_refundable_cents(&entry); let block_reason = order_refund_block_reason(&entry).map(str::to_string); // 未支付订单不能拿订单金额当实付;只有 paid_at 存在的订单才有实付金额。 let paid_amount_cents = if entry.order.paid_at_micros.is_some() { entry.order.amount_cents } else { 0 }; AdminRechargeOrderEntryPayload { order_id: entry.order.order_id, user_id: entry.order.user_id, user, product_id: entry.order.product_id, product_title: entry.order.product_title, product_kind: entry.order.kind.as_str().to_string(), amount_cents: entry.order.amount_cents, paid_amount_cents, status: entry.order.status.as_str().to_string(), payment_channel: entry.order.payment_channel, paid_at_micros: entry.order.paid_at_micros, provider_transaction_id: entry.order.provider_transaction_id, created_at_micros: entry.order.created_at_micros, points_delta: entry.order.points_delta, cumulative_success_refund_cents: settlement .map(|value| value.cumulative_success_refund_cents) .unwrap_or(0), target_recovery_points: settlement .map(|value| value.target_recovery_points) .unwrap_or(0), recovered_points: settlement.map(|value| value.recovered_points).unwrap_or(0), unrecovered_points: settlement .map(|value| value.unrecovered_points) .unwrap_or(0), recovery_status: settlement.map(|value| value.recovery_status.as_str().to_string()), wallet: map_wallet(entry.wallet, None), refunds: entry.refunds.into_iter().map(map_refund).collect(), active_hold: entry.active_hold.map(map_hold), remaining_refundable_cents, refund_eligible: block_reason.is_none(), refund_block_reason_code: block_reason, } } fn map_wallet( wallet: RuntimeProfileAdminWalletSnapshot, admin_display_names: Option<&AdminDisplayNameDirectory>, ) -> AdminProfileWalletPayload { AdminProfileWalletPayload { user_id: wallet.user_id, total_balance: wallet.total_balance, spendable_balance: wallet.spendable_balance, daily_free_points: wallet.daily_free_points, membership_limited_points: wallet.membership_limited_points, permanent_points: wallet.permanent_points, held_points: wallet.held_points, refund_debt_points: wallet.refund_debt_points, manual_frozen: wallet.manual_frozen, refund_debt_frozen: wallet.refund_debt_frozen, wallet_frozen: wallet.wallet_frozen, manual_restriction: wallet.manual_restriction.map(|value| { let created_by_admin_display_name = admin_display_names .map(|directory| directory.resolve(&value.created_by_admin_user_id)) .unwrap_or_else(|| "已停用管理员".to_string()); let updated_by_admin_display_name = admin_display_names .map(|directory| directory.resolve(&value.updated_by_admin_user_id)) .unwrap_or_else(|| "已停用管理员".to_string()); AdminWalletManualRestrictionPayload { frozen: value.frozen, reason: value.reason, created_by_admin_user_id: value.created_by_admin_user_id, created_by_admin_display_name, created_at_micros: value.created_at_micros, updated_by_admin_user_id: value.updated_by_admin_user_id, updated_by_admin_display_name, updated_at_micros: value.updated_at_micros, } }), } } fn map_refund(refund: RuntimeProfileRechargeRefundSnapshot) -> AdminRechargeRefundPayload { AdminRechargeRefundPayload { out_refund_no: refund.out_refund_no, provider_refund_id: refund.provider_refund_id, provider_transaction_id: refund.provider_transaction_id, provider_status: refund.provider_status.as_str().to_string(), total_cents: refund.total_cents, refund_cents: refund.refund_cents, payer_refund_cents: refund.payer_refund_cents, success_at_micros: refund.success_at_micros, first_observed_at_micros: refund.first_observed_at_micros, updated_at_micros: refund.updated_at_micros, observation_source: refund.last_observation_source.as_str().to_string(), target_recovery_points: refund.target_recovery_points, recovered_points: refund.recovered_points, unrecovered_points: refund.unrecovered_points, recovery_status: refund.recovery_status.as_str().to_string(), last_error_code: refund.last_error_code, manual_review_resolved_by_admin_user_id: refund.manual_review_resolved_by_admin_user_id, manual_review_resolution_reason: refund.manual_review_resolution_reason, manual_review_resolved_at_micros: refund.manual_review_resolved_at_micros, manual_review_resolved_error_code: refund.manual_review_resolved_error_code, } } fn map_hold(hold: RuntimeProfileRechargeRefundHoldSnapshot) -> AdminRechargeRefundHoldPayload { AdminRechargeRefundHoldPayload { out_refund_no: hold.out_refund_no, refund_cents: hold.refund_cents, held_points: hold.held_points, status: hold.status.as_str().to_string(), admin_user_id: hold.admin_user_id, reason: hold.reason, created_at_micros: hold.created_at_micros, updated_at_micros: hold.updated_at_micros, } } async fn refresh_auth_projection( state: &AppState, request_context: &RequestContext, ) -> Result<(), Response> { state .refresh_auth_store_from_spacetime() .await .map_err(|error| { error_response( request_context, AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("刷新用户认证信息失败:{error}")), ) }) } fn resolve_optional_user_id( state: &AppState, user_id: Option, public_user_code: Option, ) -> Result, AppError> { if user_id.as_deref().is_none_or(str::is_empty) && public_user_code.as_deref().is_none_or(str::is_empty) { return Ok(None); } resolve_user(state, user_id, public_user_code).map(|user| Some(user.id)) } fn resolve_user( state: &AppState, user_id: Option, public_user_code: Option, ) -> Result { let user_id = user_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); let public_user_code = public_user_code .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); if user_id.is_some() == public_user_code.is_some() { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("userId 与 publicUserCode 必须且只能提供一个")); } let user = if let Some(user_id) = user_id { state.auth_user_service().get_user_by_id(user_id) } else { state .auth_user_service() .get_user_by_public_user_code(public_user_code.expect("checked above")) } .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_message(format!("读取用户认证信息失败:{error}")) })?; user.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND).with_message("用户不存在")) } fn load_user_summaries( state: &AppState, entries: &[RuntimeProfileRechargeOrderAdminEntrySnapshot], ) -> BTreeMap { entries .iter() .map(|entry| entry.order.user_id.clone()) .collect::>() .into_iter() .filter_map(|user_id| load_user_summary(state, &user_id).map(|user| (user_id, user))) .collect() } fn load_user_summary(state: &AppState, user_id: &str) -> Option { state .auth_user_service() .get_user_by_id(user_id) .ok() .flatten() .as_ref() .map(map_user_summary) } fn map_user_summary(user: &AuthUser) -> AdminUserSummaryPayload { AdminUserSummaryPayload { user_id: user.id.clone(), public_user_code: user.public_user_code.clone(), display_name: user.display_name.clone(), avatar_url: user.avatar_url.clone(), } } fn parse_order_status( value: Option<&str>, ) -> Result, AppError> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; let status = match value.to_ascii_lowercase().as_str() { "pending" => RuntimeProfileRechargeOrderStatus::Pending, "paid" => RuntimeProfileRechargeOrderStatus::Paid, "failed" => RuntimeProfileRechargeOrderStatus::Failed, "closed" => RuntimeProfileRechargeOrderStatus::Closed, "refunded" => RuntimeProfileRechargeOrderStatus::Refunded, "expired" => RuntimeProfileRechargeOrderStatus::Expired, _ => { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("充值订单状态筛选值无效")); } }; Ok(Some(status)) } fn parse_optional_time_micros(value: Option<&str>, field: &str) -> Result, AppError> { value .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| { shared_kernel::parse_rfc3339(value) .map(|time| i64::try_from(time.unix_timestamp_nanos() / 1_000).unwrap_or(i64::MAX)) .map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message(format!("{field} 必须是 RFC3339 时间")) }) }) .transpose() } fn normalize_request_id(value: &str) -> Result<&str, AppError> { let value = value.trim(); if value.len() < 8 || value.len() > 128 || !value.is_ascii() { return Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_message("requestId 必须是 8 至 128 字节的 ASCII 字符串")); } Ok(value) } fn normalize_refund_reason(value: Option<&str>) -> String { value .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("管理员发起充值退款") .chars() .take(80) .collect() } fn is_likely_wechat_refund_id(value: &str) -> bool { value.len() == 29 && value.starts_with("50") && value.bytes().all(|byte| byte.is_ascii_digit()) } fn build_out_refund_no(order_id: &str, request_id: &str) -> String { let digest = Sha256::digest(format!("admin-refund\n{order_id}\n{request_id}").as_bytes()); format!("gar{}", hex::encode(&digest[..16])) } fn validate_prepared_refund_hold_status( status: RuntimeProfileRechargeRefundHoldStatus, ) -> Result<(), &'static str> { if status == RuntimeProfileRechargeRefundHoldStatus::Released { return Err("refund_hold_released"); } Ok(()) } fn refund_hold_matches_execute_request( hold: &RuntimeProfileRechargeRefundHoldSnapshot, out_refund_no: &str, refund_cents: u64, admin_user_id: &str, reason: &str, ) -> bool { hold.out_refund_no == out_refund_no && hold.refund_cents == refund_cents && hold.admin_user_id == admin_user_id && hold.reason == reason } fn build_refund_notify_url(state: &AppState) -> Result { let configured = state .config .wechat_pay_notify_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("微信支付通知地址未配置") })?; let base = configured .strip_suffix(WECHAT_PAYMENT_NOTIFY_PATH) .or_else(|| configured.strip_suffix('/')) .unwrap_or(configured); Ok(format!("{base}{WECHAT_REFUND_NOTIFY_PATH}")) } fn refund_fact_fingerprint(refund: &WechatPayRefund) -> String { let payload = format!( "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{:?}\n{:?}", refund.out_refund_no, refund.refund_id, refund.out_trade_no, refund.transaction_id, refund.status, refund.amount_total_cents, refund.amount_refund_cents, refund.amount_payer_total_cents, refund.amount_payer_refund_cents, refund.success_time, refund.create_time, ); format!("sha256:{}", hex::encode(Sha256::digest(payload.as_bytes()))) } fn short_hash(value: &[u8]) -> String { hex::encode(&Sha256::digest(value)[..8]) } fn admin_refund_observation_id( source: RuntimeProfileRechargeRefundObservationSource, fingerprint: &str, ) -> String { format!( "admin-refund-observation-{}-{}", source.as_str(), short_hash(fingerprint.as_bytes()) ) } fn is_ordinary_wechat_v3_channel(value: &str) -> bool { matches!( value, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE ) } fn refund_provider_route_block_reason( kind: RuntimeProfileRechargeProductKind, payment_channel: &str, ) -> Option<&'static str> { if kind != RuntimeProfileRechargeProductKind::Points { return Some("membership_not_supported"); } if !is_ordinary_wechat_v3_channel(payment_channel) { return Some("payment_channel_not_supported"); } None } fn validate_refund_provider_route( entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, ) -> Result<(), AppError> { if let Some(reason_code) = refund_provider_route_block_reason(entry.order.kind, &entry.order.payment_channel) { return Err(AppError::from_status(StatusCode::CONFLICT) .with_message(refund_block_message(reason_code)) .with_details(json!({"reasonCode": reason_code}))); } Ok(()) } fn classify_refund_block_reason(message: &str) -> &'static str { if message.contains("永久泥点不足") || message.contains("可用") { "insufficient_permanent_points" } else if message.contains("处理中") || message.contains("占用") || message.contains("未完成退款") || message.contains("对账") { "refund_in_progress" } else if message.contains("会员") { "membership_not_supported" } else if message.contains("渠道") { "payment_channel_not_supported" } else if message.contains("已全额退款") || message.contains("剩余") { "fully_refunded" } else { "refund_precondition_failed" } } fn refund_block_message(reason_code: &str) -> &'static str { match reason_code { "membership_not_supported" => "首期不支持会员订单退款", "payment_channel_not_supported" => "首期只支持普通微信支付 V3 泥点充值退款", "order_not_paid" => "只有已支付的充值订单可以退款", "provider_transaction_missing" => "充值订单缺少微信支付单号", "fully_refunded" => "该充值订单已无剩余可退金额", "refund_in_progress" => "该充值订单已有退款处理中", "refund_hold_released" => "该退款占用已释放,请使用新的 requestId 重新预检后发起退款", _ => "充值订单当前不可退款", } } fn spacetime_error_response( request_context: &RequestContext, error: SpacetimeClientError, ) -> Response { let message = error.to_string(); let status = if matches!(error, SpacetimeClientError::Runtime(_)) { StatusCode::BAD_REQUEST } else if matches!(error, SpacetimeClientError::Procedure(_)) { StatusCode::CONFLICT } else { StatusCode::BAD_GATEWAY }; error_response( request_context, AppError::from_status(status) .with_message(message.clone()) .with_details(json!({"provider": "spacetimedb", "message": message})), ) } fn error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } #[cfg(test)] mod tests { use axum::{ body::Body, http::{Request, StatusCode}, }; use module_runtime::{ RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot, RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, RuntimeProfileRechargeRefundRecoveryStatus, RuntimeProfileRechargeRefundSnapshot, RuntimeProfileRechargeRefundStatus, }; use tower::ServiceExt; use super::{ admin_refund_observation_id, build_out_refund_no, classify_refund_block_reason, is_likely_wechat_refund_id, is_ordinary_wechat_v3_channel, map_refund, normalize_request_id, payment_check_block_reason_code, payment_trade_state_allows_additional_refund, refund_block_message, refund_hold_matches_execute_request, refund_provider_route_block_reason, validate_prepared_refund_hold_status, }; use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] fn stable_request_id_builds_stable_wechat_refund_number() { let first = build_out_refund_no("order-1", "request-12345678"); let second = build_out_refund_no("order-1", "request-12345678"); assert_eq!(first, second); assert!(first.len() <= 64); assert_ne!(first, build_out_refund_no("order-1", "request-87654321")); } #[test] fn request_id_requires_a_bounded_ascii_value() { assert!(normalize_request_id("request-123").is_ok()); assert!(normalize_request_id("短请求编号").is_err()); assert!(normalize_request_id("short").is_err()); } #[test] fn provider_refund_id_shape_is_only_a_post_query_hint() { assert!(is_likely_wechat_refund_id("50000000000000000000000000000")); assert!(!is_likely_wechat_refund_id("merchant-refund-test-001")); assert!(!is_likely_wechat_refund_id("refund-1001")); } #[test] fn admin_refund_observation_ids_are_stable_per_source_and_fact() { let fingerprint = "sha256:refund-fact"; let api_request = admin_refund_observation_id( RuntimeProfileRechargeRefundObservationSource::ApiRequest, fingerprint, ); let repeated_api_request = admin_refund_observation_id( RuntimeProfileRechargeRefundObservationSource::ApiRequest, fingerprint, ); let query = admin_refund_observation_id( RuntimeProfileRechargeRefundObservationSource::Query, fingerprint, ); assert_eq!(api_request, repeated_api_request); assert_ne!(api_request, query); } #[test] fn refund_mapping_preserves_manual_review_evidence_and_audit_code() { let mapped = map_refund(RuntimeProfileRechargeRefundSnapshot { out_refund_no: "refund-1".to_string(), provider_refund_id: "provider-refund-1".to_string(), order_id: "order-1".to_string(), provider_transaction_id: "provider-transaction-1".to_string(), user_id: Some("user-1".to_string()), provider_status: RuntimeProfileRechargeRefundStatus::Success, total_cents: 600, refund_cents: 300, payer_total_cents: 600, payer_refund_cents: 300, success_at_micros: Some(2), first_observed_at_micros: 1, updated_at_micros: 2, last_observation_source: RuntimeProfileRechargeRefundObservationSource::Query, last_observation_id: "observation-1".to_string(), order_settled_at_micros: Some(2), target_recovery_points: 30, recovered_points: 30, unrecovered_points: 0, recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Applied, last_recovery_ledger_id: Some("ledger-1".to_string()), last_error_code: None, manual_review_resolved_by_admin_user_id: Some("admin-1".to_string()), manual_review_resolution_reason: Some("已核对商户平台".to_string()), manual_review_resolved_at_micros: Some(2), manual_review_resolved_error_code: Some("provider_transaction_id_mismatch".to_string()), }); assert_eq!(mapped.provider_transaction_id, "provider-transaction-1"); assert_eq!(mapped.total_cents, 600); assert_eq!( mapped.manual_review_resolved_error_code.as_deref(), Some("provider_transaction_id_mismatch") ); } #[test] fn refund_provider_route_uses_canonical_payment_channels() { assert!(is_ordinary_wechat_v3_channel("wechat_mp")); assert!(is_ordinary_wechat_v3_channel("wechat_jsapi")); assert!(!is_ordinary_wechat_v3_channel("wechat_mini_program")); assert!(!is_ordinary_wechat_v3_channel("wechat_mp_virtual")); assert_eq!( refund_provider_route_block_reason( RuntimeProfileRechargeProductKind::Points, "wechat_mp" ), None ); assert_eq!( refund_provider_route_block_reason( RuntimeProfileRechargeProductKind::Points, "wechat_mp_virtual" ), Some("payment_channel_not_supported") ); assert_eq!( refund_provider_route_block_reason( RuntimeProfileRechargeProductKind::Membership, "wechat_native" ), Some("membership_not_supported") ); } #[test] fn reconciled_partial_refund_keeps_the_remaining_amount_refundable() { assert!(payment_trade_state_allows_additional_refund( "SUCCESS", 0, 600 )); assert!(!payment_trade_state_allows_additional_refund( "REFUND", 0, 600 )); assert!(payment_trade_state_allows_additional_refund( "REFUND", 300, 600 )); assert!(!payment_trade_state_allows_additional_refund( "REFUND", 600, 600 )); assert_eq!( payment_check_block_reason_code("REFUND"), "wechat_refund_not_reconciled" ); } #[test] fn preview_failure_exposes_stable_reason_code() { assert_eq!( classify_refund_block_reason("永久泥点不足,无法占用"), "insufficient_permanent_points" ); assert_eq!( classify_refund_block_reason("充值订单存在未完成退款,需先完成对账"), "refund_in_progress" ); } #[test] fn released_refund_hold_cannot_reenter_provider_call() { assert_eq!( validate_prepared_refund_hold_status(RuntimeProfileRechargeRefundHoldStatus::Released), Err("refund_hold_released") ); assert_eq!( refund_block_message("refund_hold_released"), "该退款占用已释放,请使用新的 requestId 重新预检后发起退款" ); assert!( validate_prepared_refund_hold_status(RuntimeProfileRechargeRefundHoldStatus::Active) .is_ok() ); assert!( validate_prepared_refund_hold_status(RuntimeProfileRechargeRefundHoldStatus::Settled) .is_ok(), "settled hold replay must keep using the original provider refund number" ); } #[test] fn active_refund_hold_retry_requires_the_original_admin_and_reason() { let hold = RuntimeProfileRechargeRefundHoldSnapshot { out_refund_no: "refund-1".to_string(), order_id: "order-1".to_string(), user_id: "user-1".to_string(), refund_cents: 300, held_points: 30, status: RuntimeProfileRechargeRefundHoldStatus::Active, admin_user_id: "admin-1".to_string(), reason: "用户申请".to_string(), created_at_micros: 1, updated_at_micros: 1, settled_at_micros: None, released_at_micros: None, released_by_admin_user_id: None, release_reason: None, }; assert!(refund_hold_matches_execute_request( &hold, "refund-1", 300, "admin-1", "用户申请", )); assert!(!refund_hold_matches_execute_request( &hold, "refund-1", 300, "admin-2", "用户申请", )); assert!(!refund_hold_matches_execute_request( &hold, "refund-1", 300, "admin-1", "其他原因", )); } #[tokio::test] async fn recharge_management_routes_require_admin_authentication() { let app = build_router( AppState::new(AppConfig { admin_username: Some("root".to_string()), admin_password: Some("secret123".to_string()), ..AppConfig::default() }) .expect("state should build"), ); let cases = [ ("GET", "/admin/api/profile/recharge-orders", None), ("GET", "/admin/api/profile/users/detail?userId=user-1", None), ( "POST", "/admin/api/profile/users/reconcile-consumption", Some(r#"{"userId":"user-1"}"#), ), ( "POST", "/admin/api/profile/users/initialize-consumption-projections", Some("{}"), ), ( "POST", "/admin/api/profile/recharge-refunds/preview", Some(r#"{"orderId":"order-1","refundAmountCents":100}"#), ), ( "POST", "/admin/api/profile/recharge-refunds/execute", Some( r#"{"orderId":"order-1","refundAmountCents":100,"requestId":"request-12345678"}"#, ), ), ( "POST", "/admin/api/profile/recharge-refunds/register", Some(r#"{"outRefundNo":"refund-1"}"#), ), ( "POST", "/admin/api/profile/wallet-restriction", Some(r#"{"userId":"user-1","frozen":true,"reason":"人工复核"}"#), ), ]; for (method, uri, body) in cases { let mut request = Request::builder().method(method).uri(uri); if body.is_some() { request = request.header("content-type", "application/json"); } let response = app .clone() .oneshot( request .body(body.map(Body::from).unwrap_or_else(Body::empty)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); } } }