feat: 接入微信H5与Native充值支付

This commit is contained in:
2026-05-15 06:40:40 +08:00
parent 73424f958a
commit 5b70ec6af7
18 changed files with 1890 additions and 122 deletions

View File

@@ -1,12 +1,14 @@
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::Response,
};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
@@ -64,8 +66,8 @@ use crate::{
request_context::RequestContext,
state::AppState,
wechat_pay::{
WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros,
map_wechat_pay_error,
WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request,
current_unix_micros, map_wechat_pay_error,
},
};
@@ -189,13 +191,14 @@ pub async fn create_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<CreateProfileRechargeOrderRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = payload
.payment_channel
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
let payment_channel = payment_channel.trim().to_string();
let payment_channel = normalize_recharge_payment_channel(payload.payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
validate_recharge_payment_channel(&state, &payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let created_at_micros = current_unix_micros();
let (center, order) = state
.spacetime_client()
@@ -236,6 +239,43 @@ pub async fn create_profile_recharge_order(
} else {
None
};
let wechat_h5_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 {
Some(
state
.wechat_pay_client()
.create_h5_order(build_wechat_web_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
resolve_wechat_pay_client_ip(&headers),
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
)
} else {
None
};
let wechat_native_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
{
Some(
state
.wechat_pay_client()
.create_native_order(build_wechat_web_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
resolve_wechat_pay_client_ip(&headers),
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
)
} else {
None
};
Ok(json_success_body(
Some(&request_context),
@@ -243,6 +283,8 @@ pub async fn create_profile_recharge_order(
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
wechat_mini_program_pay_params,
wechat_h5_payment,
wechat_native_payment,
},
))
}
@@ -271,11 +313,11 @@ pub async fn confirm_wechat_profile_recharge_order(
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
));
}
if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM {
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("该充值订单不是微信小程序支付订单"),
.with_message("该充值订单不是微信支付订单"),
));
}
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
@@ -885,6 +927,93 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context))
}
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道不能为空")
})
}
fn validate_recharge_payment_channel(
state: &AppState,
payment_channel: &str,
) -> Result<(), AppError> {
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
if is_recharge_mock_channel_allowed(state) {
return Ok(());
}
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("生产充值不允许使用 mock 支付渠道"));
}
if is_wechat_recharge_payment_channel(payment_channel) {
validate_real_wechat_recharge_payment_provider(state)?;
return Ok(());
}
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效"))
}
fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> {
if !state.config.wechat_pay_enabled {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("微信支付真实渠道暂未启用"));
}
if state
.config
.wechat_pay_provider
.trim()
.eq_ignore_ascii_case("real")
{
return Ok(());
}
Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("真实微信支付渠道不能使用 mock 支付配置"))
}
fn is_recharge_mock_channel_allowed(state: &AppState) -> bool {
if cfg!(test) {
return state
.config
.wechat_pay_provider
.trim()
.eq_ignore_ascii_case(PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK);
}
false
}
fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
matches!(
payment_channel,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
)
}
fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
headers
.get("x-real-ip")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
})
.unwrap_or("127.0.0.1")
.to_string()
}
async fn resolve_wechat_identity_for_payment(
state: &AppState,
user_id: &str,
@@ -1503,6 +1632,153 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_order_rejects_missing_payment_channel_before_spacetime() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_60"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["error"]["message"], "充值支付渠道不能为空");
}
#[tokio::test]
async fn profile_recharge_order_rejects_unknown_payment_channel_before_spacetime() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"card"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["error"]["message"], "充值支付渠道无效");
}
#[tokio::test]
async fn profile_recharge_order_rejects_mock_when_pay_provider_is_real() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_provider: "real".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"mock"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"生产充值不允许使用 mock 支付渠道"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1720,7 +1996,11 @@ mod tests {
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
seed_authenticated_state_with_config(fast_spacetime_timeout_config()).await
}
async fn seed_authenticated_state_with_config(config: AppConfig) -> AppState {
let state = AppState::new(config).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await

View File

@@ -14,7 +14,9 @@ use ring::{
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
use shared_contracts::runtime::{
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
};
use shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime;
use tracing::{info, warn};
@@ -37,6 +39,10 @@ const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127;
const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32;
const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255;
const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128;
const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
#[derive(Clone, Debug)]
pub enum WechatPayClient {
@@ -57,6 +63,8 @@ pub struct RealWechatPayClient {
api_v3_key: String,
notify_url: String,
jsapi_endpoint: String,
h5_endpoint: String,
native_endpoint: String,
query_order_endpoint_base: String,
}
@@ -68,6 +76,14 @@ pub struct WechatMiniProgramOrderRequest {
pub payer_openid: String,
}
#[derive(Clone, Debug)]
pub struct WechatWebOrderRequest {
pub order_id: String,
pub description: String,
pub amount_cents: u64,
pub payer_client_ip: String,
}
#[derive(Clone, Debug)]
pub struct WechatPayNotifyOrder {
pub out_trade_no: String,
@@ -110,6 +126,45 @@ struct WechatJsapiPayer<'a> {
openid: &'a str,
}
#[derive(Serialize)]
struct WechatH5OrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
scene_info: WechatH5SceneInfo<'a>,
}
#[derive(Serialize)]
struct WechatH5SceneInfo<'a> {
payer_client_ip: &'a str,
h5_info: WechatH5Info,
}
#[derive(Serialize)]
struct WechatH5Info {
#[serde(rename = "type")]
kind: &'static str,
}
#[derive(Serialize)]
struct WechatNativeOrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
scene_info: WechatNativeSceneInfo<'a>,
}
#[derive(Serialize)]
struct WechatNativeSceneInfo<'a> {
payer_client_ip: &'a str,
}
#[derive(Deserialize)]
struct WechatJsapiOrderResponse {
prepay_id: Option<String>,
@@ -117,6 +172,20 @@ struct WechatJsapiOrderResponse {
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatH5OrderResponse {
h5_url: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatNativeOrderResponse {
code_url: Option<String>,
code: Option<String>,
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayNotifyBody {
#[serde(default)]
@@ -222,6 +291,10 @@ impl WechatPayClient {
&config.wechat_pay_jsapi_endpoint,
"WECHAT_PAY_JSAPI_ENDPOINT",
)?;
let h5_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_H5_PATH)?;
let native_endpoint =
resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_NATIVE_PATH)?;
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
Ok(Self::Real(Arc::new(RealWechatPayClient {
@@ -235,6 +308,8 @@ impl WechatPayClient {
api_v3_key,
notify_url,
jsapi_endpoint,
h5_endpoint,
native_endpoint,
query_order_endpoint_base,
})))
}
@@ -250,6 +325,28 @@ impl WechatPayClient {
}
}
pub async fn create_h5_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatH5PaymentResponse, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_h5_payment(&request.order_id)),
Self::Real(client) => client.create_h5_order(request).await,
}
}
pub async fn create_native_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatNativePaymentResponse, WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => Ok(build_mock_native_payment(&request.order_id)),
Self::Real(client) => client.create_native_order(request).await,
}
}
pub fn parse_notify(
&self,
headers: &HeaderMap,
@@ -304,13 +401,8 @@ impl RealWechatPayClient {
.map_err(|error| WechatPayError::Deserialize(format!("微信支付请求序列化失败:{error}")))?;
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization = self.build_authorization(
"POST",
"/v3/pay/transactions/jsapi",
&timestamp,
&nonce,
&body,
)?;
let authorization =
self.build_authorization("POST", WECHAT_PAY_JSAPI_PATH, &timestamp, &nonce, &body)?;
let response = with_wechat_pay_jsapi_headers(
self.client
.post(&self.jsapi_endpoint)
@@ -350,6 +442,147 @@ impl RealWechatPayClient {
self.build_pay_params(&prepay_id)
}
async fn create_h5_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatH5PaymentResponse, WechatPayError> {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let body = serde_json::to_string(&WechatH5OrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
currency: "CNY",
},
scene_info: WechatH5SceneInfo {
payer_client_ip: &request.payer_client_ip,
h5_info: WechatH5Info { kind: "Wap" },
},
})
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 H5 请求序列化失败:{error}"))
})?;
let response_text = self
.post_wechat_json(
&self.h5_endpoint,
WECHAT_PAY_H5_PATH,
body,
"微信支付 H5 下单请求失败",
)
.await?;
let payload =
serde_json::from_str::<WechatH5OrderResponse>(&response_text).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 H5 下单响应解析失败:{error}"))
})?;
let h5_url = payload
.h5_url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::Upstream(
payload
.message
.or(payload.code)
.unwrap_or_else(|| "微信支付未返回 h5_url".to_string()),
)
})?;
Ok(WechatH5PaymentResponse { h5_url })
}
async fn create_native_order(
&self,
request: WechatWebOrderRequest,
) -> Result<WechatNativePaymentResponse, WechatPayError> {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let body = serde_json::to_string(&WechatNativeOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
currency: "CNY",
},
scene_info: WechatNativeSceneInfo {
payer_client_ip: &request.payer_client_ip,
},
})
.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 Native 请求序列化失败:{error}"))
})?;
let response_text = self
.post_wechat_json(
&self.native_endpoint,
WECHAT_PAY_NATIVE_PATH,
body,
"微信支付 Native 下单请求失败",
)
.await?;
let payload =
serde_json::from_str::<WechatNativeOrderResponse>(&response_text).map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 Native 下单响应解析失败:{error}"))
})?;
let code_url = payload
.code_url
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WechatPayError::Upstream(
payload
.message
.or(payload.code)
.unwrap_or_else(|| "微信支付未返回 code_url".to_string()),
)
})?;
Ok(WechatNativePaymentResponse { code_url })
}
async fn post_wechat_json(
&self,
endpoint: &str,
canonical_path: &str,
body: String,
request_error_prefix: &str,
) -> Result<String, WechatPayError> {
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce = create_nonce()?;
let authorization =
self.build_authorization("POST", canonical_path, &timestamp, &nonce, &body)?;
let response = with_wechat_pay_json_headers(
self.client
.post(endpoint)
.header("Authorization", authorization),
&self.platform_serial_no,
)
.body(body)
.send()
.await
.map_err(|error| {
WechatPayError::RequestFailed(format!("{request_error_prefix}{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}"
)));
}
Ok(response_text)
}
fn build_authorization(
&self,
method: &str,
@@ -618,6 +851,20 @@ pub fn build_wechat_payment_request(
}
}
pub fn build_wechat_web_payment_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_client_ip: String,
) -> WechatWebOrderRequest {
WechatWebOrderRequest {
order_id,
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_client_ip,
}
}
pub fn current_unix_micros() -> i64 {
let value = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
i64::try_from(value).unwrap_or(i64::MAX)
@@ -664,6 +911,24 @@ fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
}
}
fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
WechatH5PaymentResponse {
h5_url: format!(
"https://mock.wechat-pay.local/h5?out_trade_no={}",
urlencoding::encode(order_id)
),
}
}
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
WechatNativePaymentResponse {
code_url: format!(
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
hex_sha256(order_id.as_bytes())
),
}
}
fn parse_mock_notify(body: &[u8]) -> Result<WechatPayNotifyOrder, WechatPayError> {
let value = serde_json::from_slice::<Value>(body).map_err(|error| {
WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}"))
@@ -744,6 +1009,20 @@ fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, Wec
Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
}
fn resolve_wechat_pay_transaction_endpoint(
jsapi_endpoint: &str,
transaction_path: &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}{transaction_path}"))
}
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
let value = value.trim();
validate_out_trade_no(value)?;
@@ -794,6 +1073,49 @@ fn validate_jsapi_order_request(
Ok(())
}
fn validate_web_order_request(
client: &RealWechatPayClient,
request: &WechatWebOrderRequest,
) -> Result<(), WechatPayError> {
validate_non_empty_max_chars(
&client.app_id,
WECHAT_PAY_APP_ID_MAX_CHARS,
"微信支付 appid",
)?;
if !client.app_id.starts_with("wx") {
return Err(WechatPayError::InvalidConfig(
"微信支付 appid 必须使用已绑定的微信 AppID".to_string(),
));
}
validate_non_empty_max_chars(
&client.mch_id,
WECHAT_PAY_MCH_ID_MAX_CHARS,
"微信支付 mchid",
)?;
if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) {
return Err(WechatPayError::InvalidConfig(
"微信支付 mchid 必须是数字字符串".to_string(),
));
}
validate_non_empty_max_chars(
&request.description,
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
"微信支付商品描述",
)?;
validate_out_trade_no(&request.order_id)?;
if request.amount_cents == 0 {
return Err(WechatPayError::InvalidRequest(
"微信支付金额必须大于 0 分".to_string(),
));
}
validate_non_empty_max_chars(
&request.payer_client_ip,
WECHAT_PAY_CLIENT_IP_MAX_CHARS,
"微信支付 payer_client_ip",
)?;
Ok(())
}
fn validate_non_empty_max_chars(
value: &str,
max_chars: usize,
@@ -1046,6 +1368,84 @@ mod tests {
assert!(body.get("notifyUrl").is_none());
}
#[test]
fn h5_order_request_uses_wechat_required_scene_info() {
let body = serde_json::to_value(WechatH5OrderRequest {
appid: "wx-test-app",
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
currency: "CNY",
},
scene_info: WechatH5SceneInfo {
payer_client_ip: "203.0.113.10",
h5_info: WechatH5Info { kind: "Wap" },
},
})
.expect("H5 order request should serialize");
assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10");
assert_eq!(body["scene_info"]["h5_info"]["type"], "Wap");
assert_eq!(body["amount"]["currency"], "CNY");
assert!(body.get("sceneInfo").is_none());
assert!(body["scene_info"].get("payerClientIp").is_none());
}
#[test]
fn native_order_request_uses_code_url_response_shape() {
let body = serde_json::to_value(WechatNativeOrderRequest {
appid: "wx-test-app",
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
currency: "CNY",
},
scene_info: WechatNativeSceneInfo {
payer_client_ip: "203.0.113.10",
},
})
.expect("Native order request should serialize");
let response = serde_json::from_value::<WechatNativeOrderResponse>(json!({
"code_url": "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test"
}))
.expect("Native order response should deserialize");
assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10");
assert_eq!(
response.code_url.as_deref(),
Some("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
);
}
#[test]
fn transaction_endpoints_reuse_configured_wechat_pay_origin() {
let h5_endpoint = resolve_wechat_pay_transaction_endpoint(
"https://pay-gateway.example.com/v3/pay/transactions/jsapi",
WECHAT_PAY_H5_PATH,
)
.expect("H5 endpoint should resolve");
let native_endpoint = resolve_wechat_pay_transaction_endpoint(
"https://pay-gateway.example.com/v3/pay/transactions/jsapi",
WECHAT_PAY_NATIVE_PATH,
)
.expect("Native endpoint should resolve");
assert_eq!(
h5_endpoint,
"https://pay-gateway.example.com/v3/pay/transactions/h5"
);
assert_eq!(
native_endpoint,
"https://pay-gateway.example.com/v3/pay/transactions/native"
);
}
#[test]
fn jsapi_order_request_rejects_provider_field_limit_violations() {
assert!(validate_out_trade_no("abc12").is_err());
@@ -1074,6 +1474,20 @@ mod tests {
validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid")
.is_err()
);
validate_non_empty_max_chars(
"203.0.113.10",
WECHAT_PAY_CLIENT_IP_MAX_CHARS,
"payer_client_ip",
)
.expect("short client ip should pass");
assert!(
validate_non_empty_max_chars(
&"1".repeat(46),
WECHAT_PAY_CLIENT_IP_MAX_CHARS,
"payer_client_ip",
)
.is_err()
);
}
#[test]