feat: 接入微信H5与Native充值支付
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
×tamp,
|
||||
&nonce,
|
||||
&body,
|
||||
)?;
|
||||
let authorization =
|
||||
self.build_authorization("POST", WECHAT_PAY_JSAPI_PATH, ×tamp, &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, ×tamp, &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]
|
||||
|
||||
Reference in New Issue
Block a user