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
|
||||
|
||||
Reference in New Issue
Block a user