1644 lines
62 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|