fix: 修复微信支付回跳刷新与查单确认

This commit is contained in:
2026-05-14 23:52:01 +08:00
parent cf3dcc6195
commit 2801b55d2f
21 changed files with 880 additions and 119 deletions

View File

@@ -129,10 +129,11 @@ use crate::{
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code, submit_profile_feedback,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -1409,6 +1410,12 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders/{order_id}/wechat/confirm",
post(confirm_wechat_profile_recharge_order).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/profile/recharge/wechat/notify",
post(handle_wechat_pay_notify),

View File

@@ -10,12 +10,12 @@ use module_runtime::{
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -25,10 +25,10 @@ use shared_contracts::runtime::{
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY,
PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED,
PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
@@ -63,7 +63,10 @@ use crate::{
http_error::AppError,
request_context::RequestContext,
state::AppState,
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
wechat_pay::{
WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros,
map_wechat_pay_error,
},
};
pub async fn get_profile_dashboard(
@@ -244,6 +247,106 @@ pub async fn create_profile_recharge_order(
))
}
pub async fn confirm_wechat_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(order_id): Path<String>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let (center, order) = state
.spacetime_client()
.get_profile_recharge_order(order_id.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
if order.user_id != user_id {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
));
}
if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("该充值订单不是微信小程序支付订单"),
));
}
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
));
}
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
));
}
let wechat_order = state
.wechat_pay_client()
.query_order_by_out_trade_no(&order.order_id)
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?;
if wechat_order.out_trade_no != order.order_id {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信支付查单返回的商户订单号与本地订单不一致")
.with_details(json!({ "provider": "wechat_pay" })),
));
}
if wechat_order.trade_state != "SUCCESS" {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
));
}
let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order);
let (center, order) = state
.spacetime_client()
.mark_profile_recharge_order_paid(
wechat_order.out_trade_no,
paid_at_micros,
wechat_order.transaction_id,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
))
}
pub async fn submit_profile_feedback(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment(
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
}
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
order
.success_time
.as_deref()
.and_then(|value| parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros)
}
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
@@ -1260,6 +1372,7 @@ mod tests {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
@@ -1271,6 +1384,20 @@ mod tests {
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let confirm_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]

View File

@@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
use shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime;
use tracing::{info, warn};
use url::Url;
use crate::{http_error::AppError, state::AppState};
@@ -28,6 +29,8 @@ const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json";
const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json";
const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0";
const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial";
const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/";
const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32;
const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32;
const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127;
@@ -54,6 +57,7 @@ pub struct RealWechatPayClient {
api_v3_key: String,
notify_url: String,
jsapi_endpoint: String,
query_order_endpoint_base: String,
}
#[derive(Clone, Debug)]
@@ -81,7 +85,7 @@ pub enum WechatPayError {
Upstream(String),
Deserialize(String),
Crypto(String),
InvalidSignature,
InvalidSignature(String),
}
#[derive(Serialize)]
@@ -137,6 +141,16 @@ struct WechatPayTransactionResource {
success_time: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayQueryOrderResponse {
out_trade_no: String,
#[serde(default)]
transaction_id: Option<String>,
trade_state: String,
#[serde(default)]
success_time: Option<String>,
}
impl WechatPayClient {
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
if !config.wechat_pay_enabled {
@@ -208,6 +222,7 @@ impl WechatPayClient {
&config.wechat_pay_jsapi_endpoint,
"WECHAT_PAY_JSAPI_ENDPOINT",
)?;
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
Ok(Self::Real(Arc::new(RealWechatPayClient {
client: reqwest::Client::new(),
@@ -220,6 +235,7 @@ impl WechatPayClient {
api_v3_key,
notify_url,
jsapi_endpoint,
query_order_endpoint_base,
})))
}
@@ -245,6 +261,22 @@ impl WechatPayClient {
Self::Real(client) => client.parse_notify(headers, body),
}
}
pub async fn query_order_by_out_trade_no(
&self,
order_id: &str,
) -> Result<WechatPayNotifyOrder, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(WechatPayNotifyOrder {
out_trade_no: normalize_out_trade_no(order_id)?,
transaction_id: Some(format!("mock-{order_id}")),
trade_state: "SUCCESS".to_string(),
success_time: Some(OffsetDateTime::now_utc().to_string()),
}),
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
}
}
}
impl RealWechatPayClient {
@@ -283,6 +315,7 @@ impl RealWechatPayClient {
self.client
.post(&self.jsapi_endpoint)
.header("Authorization", authorization),
&self.platform_serial_no,
)
.body(body)
.send()
@@ -389,6 +422,58 @@ impl RealWechatPayClient {
})
}
async fn query_order_by_out_trade_no(
&self,
order_id: &str,
) -> Result<WechatPayNotifyOrder, WechatPayError> {
let order_id = normalize_out_trade_no(order_id)?;
let path = format!(
"/v3/pay/transactions/out-trade-no/{}?mchid={}",
urlencoding::encode(&order_id),
urlencoding::encode(&self.mch_id),
);
let request_url = format!(
"{}/{}?mchid={}",
self.query_order_endpoint_base.trim_end_matches('/'),
urlencoding::encode(&order_id),
urlencoding::encode(&self.mch_id),
);
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization = self.build_authorization("GET", &path, &timestamp, &nonce, "")?;
let response = with_wechat_pay_json_headers(
self.client
.get(request_url)
.header("Authorization", authorization),
&self.platform_serial_no,
)
.send()
.await
.map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}"))
})?;
if !status.is_success() {
return Err(WechatPayError::Upstream(format!(
"微信支付查单失败HTTP {status}{response_text}"
)));
}
let payload = serde_json::from_str::<WechatPayQueryOrderResponse>(&response_text).map_err(
|error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")),
)?;
Ok(WechatPayNotifyOrder {
out_trade_no: payload.out_trade_no,
transaction_id: payload
.transaction_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
trade_state: payload.trade_state,
success_time: payload.success_time,
})
}
fn verify_notify_signature(
&self,
headers: &HeaderMap,
@@ -399,25 +484,33 @@ impl RealWechatPayClient {
let signature = read_required_header(headers, "Wechatpay-Signature")?;
let serial = read_required_header(headers, "Wechatpay-Serial")?;
if serial != self.platform_serial_no {
return Err(WechatPayError::InvalidSignature);
warn!(
received_serial = serial,
configured_serial = self.platform_serial_no.as_str(),
"微信支付通知平台公钥序列号不匹配"
);
return Err(WechatPayError::InvalidSignature(format!(
"微信支付通知平台公钥序列号不匹配received={serial}"
)));
}
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
warn!("收到微信支付签名探测通知");
return Err(WechatPayError::InvalidSignature(
"微信支付签名探测通知".to_string(),
));
}
let message = format!(
"{}\n{}\n{}\n",
timestamp,
nonce,
String::from_utf8_lossy(body)
);
let signature_bytes = BASE64_STANDARD
.decode(signature)
.map_err(|_| WechatPayError::InvalidSignature)?;
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
})?;
let public_key = signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
&self.platform_public_key_der,
);
public_key
.verify(message.as_bytes(), &signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature)
.verify(&message, &signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
}
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
@@ -499,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("微信支付通知签名无效")
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::InvalidSignature(message) => {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("微信支付通知签名无效")
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
}
}
}
@@ -533,7 +628,10 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
map_wechat_pay_error(error)
}
fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
fn with_wechat_pay_json_headers(
builder: reqwest::RequestBuilder,
platform_serial_no: &str,
) -> reqwest::RequestBuilder {
builder
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
.header(
@@ -541,6 +639,14 @@ fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> reqwest::R
WECHAT_PAY_CONTENT_TYPE_HEADER,
)
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
}
fn with_wechat_pay_jsapi_headers(
builder: reqwest::RequestBuilder,
platform_serial_no: &str,
) -> reqwest::RequestBuilder {
with_wechat_pay_json_headers(builder, platform_serial_no)
}
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
@@ -627,6 +733,23 @@ fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
Ok(())
}
fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, WechatPayError> {
let url = Url::parse(jsapi_endpoint)
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
let origin = url
.origin()
.ascii_serialization()
.trim_end_matches('/')
.to_string();
Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
}
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
let value = value.trim();
validate_out_trade_no(value)?;
Ok(value.to_string())
}
fn validate_jsapi_order_request(
client: &RealWechatPayClient,
request: &WechatMiniProgramOrderRequest,
@@ -841,7 +964,18 @@ fn read_required_header<'a>(
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(WechatPayError::InvalidSignature)
.ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头")))
}
fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec<u8> {
let mut message = Vec::with_capacity(timestamp.len() + nonce.len() + body.len() + 3);
message.extend_from_slice(timestamp);
message.push(b'\n');
message.extend_from_slice(nonce);
message.push(b'\n');
message.extend_from_slice(body);
message.push(b'\n');
message
}
fn hex_sha256(content: &[u8]) -> String {
@@ -864,7 +998,7 @@ impl std::fmt::Display for WechatPayError {
| Self::Upstream(message)
| Self::Deserialize(message)
| Self::Crypto(message) => formatter.write_str(message),
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
Self::InvalidSignature(message) => formatter.write_str(message),
}
}
}
@@ -951,6 +1085,7 @@ mod tests {
"Authorization",
"WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"",
),
"PUB_KEY_ID_0119000000012026051400000000000001",
)
.build()
.expect("request should build");
@@ -974,6 +1109,23 @@ mod tests {
.and_then(|value| value.to_str().ok()),
Some(WECHAT_PAY_USER_AGENT)
);
assert_eq!(
headers
.get(WECHAT_PAY_SERIAL_HEADER)
.and_then(|value| value.to_str().ok()),
Some("PUB_KEY_ID_0119000000012026051400000000000001")
);
}
#[test]
fn notify_signature_message_preserves_raw_body_bytes() {
let body = b"{\"message\":\"hello\\r\\nworld\"}\r\n";
let message = build_notify_signature_message(b"1778759600", b"nonce-1", body);
assert_eq!(
message,
b"1778759600\nnonce-1\n{\"message\":\"hello\\r\\nworld\"}\r\n\n".to_vec()
);
}
#[test]