Files
Genarrative/server-rs/crates/api-server/src/admin_recharge.rs
T
kdletters 271d5d7bd3
Project CI / Repository checks (push) Successful in 59s
Project CI / Frontend tests (push) Successful in 1m58s
Project CI / Native shell tests (push) Successful in 2m28s
Project CI / Backend tests (push) Successful in 3m5s
持久化用户历史消费泥点投影
后台用户详情展示历史花费并保留手动对账
消费落账原子更新投影并提供存量初始化
为手动对账增加独立管理员操作权限
更新SpacetimeDB绑定、文档和测试
2026-07-28 16:20:43 +08:00

1644 lines
62 KiB
Rust

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, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminRechargeOrderListQuery>,
) -> Result<Json<Value>, Response> {
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminUserDetailQuery>,
) -> Result<Json<Value>, Response> {
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))?;
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,
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUserConsumptionReconcileRequest>,
) -> Result<Json<Value>, Response> {
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminRechargeRefundPreviewRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminRechargeRefundExecuteRequest>,
) -> Result<Response, Response> {
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminRechargeRefundRegisterRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminRechargeRefundManualReviewResolveRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminWalletRestrictionRequest>,
) -> Result<Json<Value>, Response> {
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<RuntimeProfileRechargeOrderAdminEntrySnapshot, Response> {
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<AdminWechatPaymentCheckPayload, AppError> {
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<u32, AppError> {
let mut out_refund_nos = entry
.refunds
.iter()
.map(|refund| refund.out_refund_no.clone())
.collect::<BTreeSet<_>>();
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<AdminUserSummaryPayload>,
) -> 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<AdminUserSummaryPayload>,
) -> 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);
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,
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,
}
}
fn resolve_optional_user_id(
state: &AppState,
user_id: Option<String>,
public_user_code: Option<String>,
) -> Result<Option<String>, 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<String>,
public_user_code: Option<String>,
) -> Result<AuthUser, AppError> {
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<String, AdminUserSummaryPayload> {
entries
.iter()
.map(|entry| entry.order.user_id.clone())
.collect::<BTreeSet<_>>()
.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<AdminUserSummaryPayload> {
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<Option<RuntimeProfileRechargeOrderStatus>, 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<Option<i64>, 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<String, AppError> {
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}");
}
}
}