Merge branch 'hermes/wechat'
# Conflicts: # .hermes/shared-memory/decision-log.md # docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md # docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md # server-rs/crates/module-runtime/src/errors.rs # src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE};
|
||||
use module_auth::{
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionError,
|
||||
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, LogoutError, RefreshSessionClientInfo,
|
||||
RefreshSessionError,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AccessTokenDeviceInfo, AuthProvider, BindingStatus,
|
||||
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
|
||||
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::session_client::SessionClientContext;
|
||||
use crate::{
|
||||
http_error::AppError, request_context::RequestContext, state::AppState,
|
||||
tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
use crate::tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path;
|
||||
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignedAuthSession {
|
||||
@@ -81,6 +81,7 @@ pub fn create_auth_session(
|
||||
user,
|
||||
&session.session.session_id,
|
||||
Some(&session_provider),
|
||||
Some(&session.session.client_info),
|
||||
)?;
|
||||
|
||||
Ok(SignedAuthSession {
|
||||
@@ -94,8 +95,9 @@ pub fn sign_access_token_for_user(
|
||||
user: &AuthUser,
|
||||
session_id: &str,
|
||||
session_provider_override: Option<&AuthLoginMethod>,
|
||||
client_info: Option<&RefreshSessionClientInfo>,
|
||||
) -> Result<String, AppError> {
|
||||
let access_claims = AccessTokenClaims::from_input(
|
||||
let access_claims = AccessTokenClaims::from_input_with_device(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
@@ -106,6 +108,7 @@ pub fn sign_access_token_for_user(
|
||||
binding_status: map_binding_status(&user.binding_status),
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
client_info.map(map_access_token_device_info),
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
@@ -182,3 +185,11 @@ fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> Bindin
|
||||
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_access_token_device_info(client_info: &RefreshSessionClientInfo) -> AccessTokenDeviceInfo {
|
||||
AccessTokenDeviceInfo {
|
||||
client_type: client_info.client_type.clone(),
|
||||
client_runtime: client_info.client_runtime.clone(),
|
||||
client_platform: client_info.client_platform.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub async fn refresh_session(
|
||||
&rotated.user,
|
||||
&rotated.session.session_id,
|
||||
Some(&rotated.session.issued_by_provider),
|
||||
Some(&rotated.session.client_info),
|
||||
)?;
|
||||
record_daily_login_tracking_event_after_auth_success(
|
||||
&state,
|
||||
|
||||
@@ -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,
|
||||
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
|
||||
@@ -71,8 +73,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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -196,13 +198,16 @@ 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_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();
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
@@ -243,6 +248,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),
|
||||
@@ -250,6 +292,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,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -278,11 +322,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 {
|
||||
@@ -970,6 +1014,121 @@ 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_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)
|
||||
.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,
|
||||
@@ -1649,6 +1808,280 @@ 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_non_wechat_device_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_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()
|
||||
.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"));
|
||||
@@ -1867,7 +2300,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
|
||||
@@ -1910,4 +2347,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -260,7 +260,7 @@ pub fn build_runtime_profile_recharge_order_create_input(
|
||||
let product_id =
|
||||
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
|
||||
let payment_channel = normalize_required_string(payment_channel)
|
||||
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
|
||||
.ok_or(RuntimeProfileFieldError::MissingPaymentChannel)?;
|
||||
|
||||
Ok(RuntimeProfileRechargeOrderCreateInput {
|
||||
user_id,
|
||||
|
||||
@@ -34,6 +34,8 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
|
||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
|
||||
pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200;
|
||||
pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40;
|
||||
|
||||
@@ -80,6 +80,7 @@ pub enum RuntimeProfileFieldError {
|
||||
InvalidRechargeProductDuration,
|
||||
InvalidRechargeProductKind,
|
||||
InvalidRechargeProductTier,
|
||||
MissingPaymentChannel,
|
||||
MissingWorldKey,
|
||||
MissingBottomTab,
|
||||
MissingCheckpointSessionId,
|
||||
@@ -150,6 +151,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
}
|
||||
Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"),
|
||||
Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"),
|
||||
Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"),
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),
|
||||
|
||||
@@ -745,6 +745,19 @@ mod tests {
|
||||
assert_eq!(input.payment_channel, "mock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_recharge_order_input_rejects_missing_payment_channel() {
|
||||
let error = build_runtime_profile_recharge_order_create_input(
|
||||
"user-1".to_string(),
|
||||
"points_60".to_string(),
|
||||
" ".to_string(),
|
||||
1,
|
||||
)
|
||||
.expect_err("missing payment channel should fail");
|
||||
|
||||
assert_eq!(error, RuntimeProfileFieldError::MissingPaymentChannel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_profile_identity_helpers_keep_existing_key_shape() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
本阶段已经完成 JWT 基础能力首版落地:
|
||||
|
||||
1. 新增 `JwtConfig`,统一管理 `issuer`、`secret` 与 access token TTL。
|
||||
2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。
|
||||
2. 新增 `AccessTokenClaimsInput` 与 `AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name/device` 映射到 Rust 结构。
|
||||
3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。
|
||||
4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。
|
||||
5. 新增 `RefreshCookieConfig`、`RefreshCookieSameSite` 与 `read_refresh_session_token(...)`,统一 refresh cookie 读取口径。
|
||||
@@ -36,13 +36,14 @@
|
||||
|
||||
1. `JwtConfig::new(...)`
|
||||
2. `AccessTokenClaims::from_input(...)`
|
||||
3. `sign_access_token(...)`
|
||||
4. `verify_access_token(...)`
|
||||
5. `RefreshCookieConfig::new(...)`
|
||||
6. `read_refresh_session_token(...)`
|
||||
7. `AuthProvider`
|
||||
8. `BindingStatus`
|
||||
9. `RefreshCookieSameSite`
|
||||
3. `AccessTokenClaims::from_input_with_device(...)`
|
||||
4. `sign_access_token(...)`
|
||||
5. `verify_access_token(...)`
|
||||
6. `RefreshCookieConfig::new(...)`
|
||||
7. `read_refresh_session_token(...)`
|
||||
8. `AuthProvider`
|
||||
9. `BindingStatus`
|
||||
10. `RefreshCookieSameSite`
|
||||
|
||||
## 4. 配置口径
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
|
||||
1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。
|
||||
2. `sub` 必须是稳定 `user_id`,`sid` 必须是会话 ID,不能退化为一次 token 的随机 ID。
|
||||
3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。
|
||||
3. 不允许把手机号、openid、refresh token hash、风控状态、完整设备对象、IP、UA 等敏感或高频变化字段塞进 JWT;`device` 只允许保存支付拦截需要的最小设备快照。
|
||||
4. 鉴权状态最终由 `module-auth` 与 `crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。
|
||||
5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。
|
||||
|
||||
|
||||
@@ -66,6 +66,15 @@ pub enum BindingStatus {
|
||||
PendingBindPhone,
|
||||
}
|
||||
|
||||
// JWT 里只保留一份规范化后的设备快照,用于后端按登录设备拦截充值路径。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct AccessTokenDeviceInfo {
|
||||
pub client_type: String,
|
||||
pub client_runtime: String,
|
||||
pub client_platform: String,
|
||||
}
|
||||
|
||||
// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AccessTokenClaimsInput {
|
||||
@@ -92,6 +101,8 @@ pub struct AccessTokenClaims {
|
||||
pub binding_status: BindingStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub device: Option<AccessTokenDeviceInfo>,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
@@ -1481,11 +1492,21 @@ impl AccessTokenClaims {
|
||||
input: AccessTokenClaimsInput,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
Self::from_input_with_device(input, None, config, issued_at)
|
||||
}
|
||||
|
||||
pub fn from_input_with_device(
|
||||
input: AccessTokenClaimsInput,
|
||||
device: Option<AccessTokenDeviceInfo>,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?;
|
||||
let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?;
|
||||
let roles = normalize_roles(input.roles)?;
|
||||
let display_name = normalize_optional_field(input.display_name);
|
||||
let device = device.map(|device| device.normalize()).transpose()?;
|
||||
|
||||
let issued_at_unix = issued_at.unix_timestamp();
|
||||
if issued_at_unix < 0 {
|
||||
@@ -1515,6 +1536,7 @@ impl AccessTokenClaims {
|
||||
phone_verified: input.phone_verified,
|
||||
binding_status: input.binding_status,
|
||||
display_name,
|
||||
device,
|
||||
iat: issued_at_unix as u64,
|
||||
exp: expires_at_unix as u64,
|
||||
};
|
||||
@@ -1535,6 +1557,46 @@ impl AccessTokenClaims {
|
||||
self.ver
|
||||
}
|
||||
|
||||
pub fn client_type(&self) -> Option<&str> {
|
||||
self.device
|
||||
.as_ref()
|
||||
.map(|device| device.client_type.as_str())
|
||||
}
|
||||
|
||||
pub fn client_platform(&self) -> Option<&str> {
|
||||
self.device
|
||||
.as_ref()
|
||||
.map(|device| device.client_platform.as_str())
|
||||
}
|
||||
|
||||
pub fn is_wechat_mini_program_device(&self) -> bool {
|
||||
matches!(self.client_type(), Some("mini_program"))
|
||||
}
|
||||
|
||||
pub fn is_wechat_h5_device(&self) -> bool {
|
||||
matches!(self.client_type(), Some("wechat_h5"))
|
||||
}
|
||||
|
||||
pub fn is_wechat_payment_device(&self) -> bool {
|
||||
self.is_wechat_mini_program_device() || self.is_wechat_h5_device()
|
||||
}
|
||||
|
||||
pub fn is_mobile_device(&self) -> bool {
|
||||
matches!(self.client_platform(), Some("ios" | "android"))
|
||||
}
|
||||
|
||||
pub fn is_desktop_device(&self) -> bool {
|
||||
matches!(self.client_platform(), Some("windows" | "macos" | "linux"))
|
||||
}
|
||||
|
||||
pub fn is_mobile_wechat_browser_device(&self) -> bool {
|
||||
self.is_wechat_h5_device() && self.is_mobile_device()
|
||||
}
|
||||
|
||||
pub fn is_desktop_wechat_browser_device(&self) -> bool {
|
||||
self.is_wechat_h5_device() && self.is_desktop_device()
|
||||
}
|
||||
|
||||
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
|
||||
if self.iss.trim() != config.issuer() {
|
||||
return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致"));
|
||||
@@ -1543,6 +1605,9 @@ impl AccessTokenClaims {
|
||||
normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?;
|
||||
normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?;
|
||||
normalize_roles(self.roles.clone())?;
|
||||
if let Some(device) = &self.device {
|
||||
device.validate()?;
|
||||
}
|
||||
|
||||
if self.exp <= self.iat {
|
||||
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
|
||||
@@ -1552,6 +1617,38 @@ impl AccessTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenDeviceInfo {
|
||||
pub fn normalize(self) -> Result<Self, JwtError> {
|
||||
Ok(Self {
|
||||
client_type: normalize_required_field(
|
||||
self.client_type,
|
||||
"JWT device.client_type 不能为空",
|
||||
)?,
|
||||
client_runtime: normalize_required_field(
|
||||
self.client_runtime,
|
||||
"JWT device.client_runtime 不能为空",
|
||||
)?,
|
||||
client_platform: normalize_required_field(
|
||||
self.client_platform,
|
||||
"JWT device.client_platform 不能为空",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), JwtError> {
|
||||
normalize_required_field(self.client_type.clone(), "JWT device.client_type 不能为空")?;
|
||||
normalize_required_field(
|
||||
self.client_runtime.clone(),
|
||||
"JWT device.client_runtime 不能为空",
|
||||
)?;
|
||||
normalize_required_field(
|
||||
self.client_platform.clone(),
|
||||
"JWT device.client_platform 不能为空",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign_access_token(
|
||||
claims: &AccessTokenClaims,
|
||||
config: &JwtConfig,
|
||||
|
||||
@@ -245,6 +245,18 @@ pub struct WechatMiniProgramPayParamsResponse {
|
||||
pub pay_sign: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatH5PaymentResponse {
|
||||
pub h5_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatNativePaymentResponse {
|
||||
pub code_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileRechargeCenterResponse {
|
||||
@@ -272,6 +284,10 @@ pub struct CreateProfileRechargeOrderResponse {
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
#[serde(default)]
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||
#[serde(default)]
|
||||
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
|
||||
#[serde(default)]
|
||||
pub wechat_native_payment: Option<WechatNativePaymentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -1381,6 +1397,60 @@ mod tests {
|
||||
assert_eq!(payload.payment_channel, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_recharge_order_response_serializes_web_wechat_payloads() {
|
||||
let order = ProfileRechargeOrderResponse {
|
||||
order_id: "rcgtest001".to_string(),
|
||||
product_id: "points_60".to_string(),
|
||||
product_title: "60泥点".to_string(),
|
||||
kind: "points".to_string(),
|
||||
amount_cents: 600,
|
||||
status: "pending".to_string(),
|
||||
payment_channel: "wechat_native".to_string(),
|
||||
paid_at: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-15T10:00:00Z".to_string(),
|
||||
points_delta: 0,
|
||||
membership_expires_at: None,
|
||||
};
|
||||
let center = ProfileRechargeCenterResponse {
|
||||
wallet_balance: 0,
|
||||
membership: ProfileMembershipResponse {
|
||||
status: "normal".to_string(),
|
||||
tier: "normal".to_string(),
|
||||
started_at: None,
|
||||
expires_at: None,
|
||||
updated_at: None,
|
||||
},
|
||||
point_products: vec![],
|
||||
membership_products: vec![],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
let payload = serde_json::to_value(CreateProfileRechargeOrderResponse {
|
||||
order,
|
||||
center,
|
||||
wechat_mini_program_pay_params: None,
|
||||
wechat_h5_payment: Some(WechatH5PaymentResponse {
|
||||
h5_url: "https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb".to_string(),
|
||||
}),
|
||||
wechat_native_payment: Some(WechatNativePaymentResponse {
|
||||
code_url: "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test".to_string(),
|
||||
}),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["wechatH5Payment"]["h5Url"],
|
||||
json!("https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatNativePayment"]["codeUrl"],
|
||||
json!("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_feedback_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
|
||||
|
||||
Reference in New Issue
Block a user