feat: gate recharge payment by login device

This commit is contained in:
2026-05-15 08:43:21 +08:00
parent 5b70ec6af7
commit c94f22e26c
12 changed files with 527 additions and 72 deletions

View File

@@ -197,6 +197,8 @@ pub async fn create_profile_recharge_order(
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = normalize_recharge_payment_channel(payload.payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
validate_recharge_device_for_payment_channel(authenticated.claims(), &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();
@@ -956,6 +958,34 @@ fn validate_recharge_payment_channel(
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效"))
}
fn validate_recharge_device_for_payment_channel(
claims: &platform_auth::AccessTokenClaims,
payment_channel: &str,
) -> Result<(), AppError> {
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
return Ok(());
}
if !is_wechat_recharge_payment_channel(payment_channel) {
return Ok(());
}
let is_supported_device = match payment_channel {
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
_ => false,
};
if is_supported_device {
return Ok(());
}
Err(AppError::from_status(StatusCode::FORBIDDEN)
.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)
@@ -1738,7 +1768,7 @@ mod tests {
}
#[tokio::test]
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
@@ -1749,6 +1779,133 @@ mod tests {
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::FORBIDDEN);
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_mismatched_wechat_channel_before_spacetime() {
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_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5");
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_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
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_allows_desktop_wechat_native_channel_before_provider_check() {
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_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat");
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_native"}"#,
))
.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_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_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
@@ -2043,4 +2200,34 @@ mod tests {
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn issue_wechat_h5_access_token(
state: &AppState,
client_platform: &str,
session_id: &str,
) -> String {
let claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", session_id),
provider: AuthProvider::Wechat,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("微信资料页用户".to_string()),
},
Some(platform_auth::AccessTokenDeviceInfo {
client_type: "wechat_h5".to_string(),
client_runtime: "wechat_embedded_browser".to_string(),
client_platform: client_platform.to_string(),
}),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}