Merge branch 'master' of https://git.genarrative.world/GenarrativeAI/Genarrative
This commit is contained in:
@@ -99,6 +99,7 @@
|
||||
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。
|
||||
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。
|
||||
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
|
||||
4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
|
||||
当前 request context 约定:
|
||||
|
||||
|
||||
@@ -136,10 +136,11 @@ use crate::{
|
||||
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
|
||||
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
|
||||
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
|
||||
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
|
||||
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
|
||||
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
|
||||
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
|
||||
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
|
||||
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
|
||||
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
|
||||
redeem_profile_reward_code, submit_profile_feedback,
|
||||
},
|
||||
runtime_save::{
|
||||
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
|
||||
@@ -1488,6 +1489,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/orders/{order_id}/wechat/confirm",
|
||||
post(confirm_wechat_profile_recharge_order).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/wechat/notify",
|
||||
post(handle_wechat_pay_notify),
|
||||
|
||||
@@ -10,12 +10,12 @@ use module_runtime::{
|
||||
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
|
||||
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeTrackingScopeKind,
|
||||
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord,
|
||||
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
||||
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
|
||||
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
|
||||
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -25,10 +25,10 @@ use shared_contracts::runtime::{
|
||||
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
|
||||
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||
PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
|
||||
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
|
||||
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY,
|
||||
PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED,
|
||||
PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
|
||||
@@ -63,7 +63,10 @@ use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
|
||||
wechat_pay::{
|
||||
WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros,
|
||||
map_wechat_pay_error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
@@ -244,6 +247,106 @@ pub async fn create_profile_recharge_order(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn confirm_wechat_profile_recharge_order(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(order_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.get_profile_recharge_order(order_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
if order.user_id != user_id {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
|
||||
));
|
||||
}
|
||||
if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("该充值订单不是微信小程序支付订单"),
|
||||
));
|
||||
}
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let wechat_order = state
|
||||
.wechat_pay_client()
|
||||
.query_order_by_out_trade_no(&order.order_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
||||
})?;
|
||||
if wechat_order.out_trade_no != order.order_id {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信支付查单返回的商户订单号与本地订单不一致")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
));
|
||||
}
|
||||
if wechat_order.trade_state != "SUCCESS" {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order);
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
wechat_order.out_trade_no,
|
||||
paid_at_micros,
|
||||
wechat_order.transaction_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_profile_feedback(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment(
|
||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
||||
}
|
||||
|
||||
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
||||
order
|
||||
.success_time
|
||||
.as_deref()
|
||||
.and_then(|value| parse_rfc3339(value).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.unwrap_or_else(current_unix_micros)
|
||||
}
|
||||
|
||||
fn build_profile_recharge_center_response(
|
||||
record: RuntimeProfileRechargeCenterRecord,
|
||||
) -> ProfileRechargeCenterResponse {
|
||||
@@ -1260,6 +1372,7 @@ mod tests {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
@@ -1271,6 +1384,20 @@ mod tests {
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let confirm_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
@@ -25,7 +26,17 @@ const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
|
||||
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
||||
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
||||
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
||||
const WECHAT_PAY_NOTIFY_SUCCESS: &str = "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
||||
const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json";
|
||||
const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json";
|
||||
const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0";
|
||||
const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial";
|
||||
const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/";
|
||||
const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32;
|
||||
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;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatPayClient {
|
||||
@@ -46,6 +57,7 @@ pub struct RealWechatPayClient {
|
||||
api_v3_key: String,
|
||||
notify_url: String,
|
||||
jsapi_endpoint: String,
|
||||
query_order_endpoint_base: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -73,11 +85,10 @@ pub enum WechatPayError {
|
||||
Upstream(String),
|
||||
Deserialize(String),
|
||||
Crypto(String),
|
||||
InvalidSignature,
|
||||
InvalidSignature(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WechatJsapiOrderRequest<'a> {
|
||||
appid: &'a str,
|
||||
mchid: &'a str,
|
||||
@@ -130,6 +141,16 @@ struct WechatPayTransactionResource {
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WechatPayQueryOrderResponse {
|
||||
out_trade_no: String,
|
||||
#[serde(default)]
|
||||
transaction_id: Option<String>,
|
||||
trade_state: String,
|
||||
#[serde(default)]
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
impl WechatPayClient {
|
||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.wechat_pay_enabled {
|
||||
@@ -196,10 +217,12 @@ impl WechatPayClient {
|
||||
config.wechat_pay_notify_url.as_deref(),
|
||||
"WECHAT_PAY_NOTIFY_URL",
|
||||
)?;
|
||||
validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?;
|
||||
let jsapi_endpoint = normalize_required_url(
|
||||
&config.wechat_pay_jsapi_endpoint,
|
||||
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||
)?;
|
||||
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
|
||||
|
||||
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||||
client: reqwest::Client::new(),
|
||||
@@ -212,6 +235,7 @@ impl WechatPayClient {
|
||||
api_v3_key,
|
||||
notify_url,
|
||||
jsapi_endpoint,
|
||||
query_order_endpoint_base,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -237,6 +261,22 @@ impl WechatPayClient {
|
||||
Self::Real(client) => client.parse_notify(headers, body),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_order_by_out_trade_no(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
match self {
|
||||
Self::Disabled => Err(WechatPayError::Disabled),
|
||||
Self::Mock => Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: normalize_out_trade_no(order_id)?,
|
||||
transaction_id: Some(format!("mock-{order_id}")),
|
||||
trade_state: "SUCCESS".to_string(),
|
||||
success_time: Some(OffsetDateTime::now_utc().to_string()),
|
||||
}),
|
||||
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RealWechatPayClient {
|
||||
@@ -244,6 +284,7 @@ impl RealWechatPayClient {
|
||||
&self,
|
||||
request: WechatMiniProgramOrderRequest,
|
||||
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||
validate_jsapi_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(&WechatJsapiOrderRequest {
|
||||
@@ -270,18 +311,18 @@ impl RealWechatPayClient {
|
||||
&nonce,
|
||||
&body,
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.post(&self.jsapi_endpoint)
|
||||
.header("Authorization", authorization)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
||||
})?;
|
||||
let response = with_wechat_pay_jsapi_headers(
|
||||
self.client
|
||||
.post(&self.jsapi_endpoint)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
|
||||
@@ -381,6 +422,58 @@ impl RealWechatPayClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_order_by_out_trade_no(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
let order_id = normalize_out_trade_no(order_id)?;
|
||||
let path = format!(
|
||||
"/v3/pay/transactions/out-trade-no/{}?mchid={}",
|
||||
urlencoding::encode(&order_id),
|
||||
urlencoding::encode(&self.mch_id),
|
||||
);
|
||||
let request_url = format!(
|
||||
"{}/{}?mchid={}",
|
||||
self.query_order_endpoint_base.trim_end_matches('/'),
|
||||
urlencoding::encode(&order_id),
|
||||
urlencoding::encode(&self.mch_id),
|
||||
);
|
||||
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce = create_nonce()?;
|
||||
let authorization = self.build_authorization("GET", &path, ×tamp, &nonce, "")?;
|
||||
let response = with_wechat_pay_json_headers(
|
||||
self.client
|
||||
.get(request_url)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{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}"
|
||||
)));
|
||||
}
|
||||
let payload = serde_json::from_str::<WechatPayQueryOrderResponse>(&response_text).map_err(
|
||||
|error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")),
|
||||
)?;
|
||||
|
||||
Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: payload.out_trade_no,
|
||||
transaction_id: payload
|
||||
.transaction_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
trade_state: payload.trade_state,
|
||||
success_time: payload.success_time,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_notify_signature(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
@@ -391,25 +484,33 @@ impl RealWechatPayClient {
|
||||
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
||||
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
||||
if serial != self.platform_serial_no {
|
||||
return Err(WechatPayError::InvalidSignature);
|
||||
warn!(
|
||||
received_serial = serial,
|
||||
configured_serial = self.platform_serial_no.as_str(),
|
||||
"微信支付通知平台公钥序列号不匹配"
|
||||
);
|
||||
return Err(WechatPayError::InvalidSignature(format!(
|
||||
"微信支付通知平台公钥序列号不匹配:received={serial}"
|
||||
)));
|
||||
}
|
||||
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
|
||||
warn!("收到微信支付签名探测通知");
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信支付签名探测通知".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"{}\n{}\n{}\n",
|
||||
timestamp,
|
||||
nonce,
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
let signature_bytes = BASE64_STANDARD
|
||||
.decode(signature)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)?;
|
||||
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
|
||||
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
|
||||
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
|
||||
})?;
|
||||
let public_key = signature::UnparsedPublicKey::new(
|
||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||
&self.platform_public_key_der,
|
||||
);
|
||||
public_key
|
||||
.verify(message.as_bytes(), &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)
|
||||
.verify(&message, &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
|
||||
}
|
||||
|
||||
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
||||
@@ -431,7 +532,7 @@ pub async fn handle_wechat_pay_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<&'static str, AppError> {
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let notify = state
|
||||
.wechat_pay_client()
|
||||
.parse_notify(&headers, &body)
|
||||
@@ -442,7 +543,7 @@ pub async fn handle_wechat_pay_notify(
|
||||
trade_state = notify.trade_state.as_str(),
|
||||
"收到非成功微信支付通知"
|
||||
);
|
||||
return Ok(WECHAT_PAY_NOTIFY_SUCCESS);
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify
|
||||
@@ -469,7 +570,7 @@ pub async fn handle_wechat_pay_notify(
|
||||
"微信支付通知已确认订单入账"
|
||||
);
|
||||
|
||||
Ok(WECHAT_PAY_NOTIFY_SUCCESS)
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
@@ -491,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +628,27 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_json_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
builder
|
||||
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
WECHAT_PAY_CONTENT_TYPE_HEADER,
|
||||
)
|
||||
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
|
||||
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_jsapi_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
with_wechat_pay_json_headers(builder, platform_serial_no)
|
||||
}
|
||||
|
||||
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce_str = "mock-nonce".to_string();
|
||||
@@ -595,6 +719,122 @@ fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayErr
|
||||
)))
|
||||
}
|
||||
|
||||
fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
|
||||
if value.chars().count() > WECHAT_PAY_NOTIFY_URL_MAX_CHARS {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"{key} 不能超过 {WECHAT_PAY_NOTIFY_URL_MAX_CHARS} 字符"
|
||||
)));
|
||||
}
|
||||
if value.contains('?') || value.contains('#') {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"{key} 不能包含 query 或 fragment"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_query_order_endpoint_base(jsapi_endpoint: &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}/v3/pay/transactions/out-trade-no"))
|
||||
}
|
||||
|
||||
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
|
||||
let value = value.trim();
|
||||
validate_out_trade_no(value)?;
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_jsapi_order_request(
|
||||
client: &RealWechatPayClient,
|
||||
request: &WechatMiniProgramOrderRequest,
|
||||
) -> 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_openid,
|
||||
WECHAT_PAY_OPENID_MAX_CHARS,
|
||||
"微信支付 payer.openid",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_non_empty_max_chars(
|
||||
value: &str,
|
||||
max_chars: usize,
|
||||
field_name: &str,
|
||||
) -> Result<(), WechatPayError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(WechatPayError::InvalidRequest(format!(
|
||||
"{field_name} 不能为空"
|
||||
)));
|
||||
}
|
||||
if value.chars().count() > max_chars {
|
||||
return Err(WechatPayError::InvalidRequest(format!(
|
||||
"{field_name} 不能超过 {max_chars} 字符"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_out_trade_no(value: &str) -> Result<(), WechatPayError> {
|
||||
validate_non_empty_max_chars(
|
||||
value,
|
||||
WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS,
|
||||
"微信支付 out_trade_no",
|
||||
)?;
|
||||
if value.chars().count() < 6 {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付 out_trade_no 不能少于 6 字符".to_string(),
|
||||
));
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*'))
|
||||
{
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付 out_trade_no 只能包含数字、大小写字母、_、-、|、*".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_private_key_pem(
|
||||
inline_pem: Option<&str>,
|
||||
path: Option<&Path>,
|
||||
@@ -724,7 +964,18 @@ fn read_required_header<'a>(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatPayError::InvalidSignature)
|
||||
.ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头")))
|
||||
}
|
||||
|
||||
fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut message = Vec::with_capacity(timestamp.len() + nonce.len() + body.len() + 3);
|
||||
message.extend_from_slice(timestamp);
|
||||
message.push(b'\n');
|
||||
message.extend_from_slice(nonce);
|
||||
message.push(b'\n');
|
||||
message.extend_from_slice(body);
|
||||
message.push(b'\n');
|
||||
message
|
||||
}
|
||||
|
||||
fn hex_sha256(content: &[u8]) -> String {
|
||||
@@ -747,7 +998,7 @@ impl std::fmt::Display for WechatPayError {
|
||||
| Self::Upstream(message)
|
||||
| Self::Deserialize(message)
|
||||
| Self::Crypto(message) => formatter.write_str(message),
|
||||
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
|
||||
Self::InvalidSignature(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,6 +1019,115 @@ mod tests {
|
||||
assert!(!params.pay_sign.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_uses_wechat_v3_snake_case_fields() {
|
||||
let body = serde_json::to_value(WechatJsapiOrderRequest {
|
||||
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",
|
||||
},
|
||||
payer: WechatJsapiPayer {
|
||||
openid: "openid-test",
|
||||
},
|
||||
})
|
||||
.expect("JSAPI order request should serialize");
|
||||
|
||||
assert_eq!(body["out_trade_no"], "rcgtest001");
|
||||
assert_eq!(
|
||||
body["notify_url"],
|
||||
"https://api.example.com/api/profile/recharge/wechat/notify"
|
||||
);
|
||||
assert!(body.get("outTradeNo").is_none());
|
||||
assert!(body.get("notifyUrl").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_rejects_provider_field_limit_violations() {
|
||||
assert!(validate_out_trade_no("abc12").is_err());
|
||||
assert!(validate_out_trade_no("abc123").is_ok());
|
||||
assert!(validate_out_trade_no("abc123_-|*").is_ok());
|
||||
assert!(validate_out_trade_no("abc123中文").is_err());
|
||||
assert!(validate_out_trade_no("a".repeat(33).as_str()).is_err());
|
||||
|
||||
assert!(validate_notify_url("https://api.example.com/pay/notify", "notify").is_ok());
|
||||
assert!(validate_notify_url("https://api.example.com/pay/notify?x=1", "notify").is_err());
|
||||
assert!(validate_notify_url(&format!("https://{}", "a".repeat(248)), "notify").is_err());
|
||||
|
||||
validate_non_empty_max_chars("陶泥儿 - 60泥点", WECHAT_PAY_DESCRIPTION_MAX_CHARS, "描述")
|
||||
.expect("short description should pass");
|
||||
assert!(
|
||||
validate_non_empty_max_chars(
|
||||
&"泥".repeat(128),
|
||||
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
|
||||
"描述"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
validate_non_empty_max_chars("openid-test", WECHAT_PAY_OPENID_MAX_CHARS, "openid")
|
||||
.expect("short openid should pass");
|
||||
assert!(
|
||||
validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_sets_wechat_required_http_headers() {
|
||||
let request = with_wechat_pay_jsapi_headers(
|
||||
reqwest::Client::new()
|
||||
.post("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi")
|
||||
.header(
|
||||
"Authorization",
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"",
|
||||
),
|
||||
"PUB_KEY_ID_0119000000012026051400000000000001",
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
let headers = request.headers();
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(reqwest::header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_ACCEPT_HEADER)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_CONTENT_TYPE_HEADER)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(reqwest::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_USER_AGENT)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(WECHAT_PAY_SERIAL_HEADER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("PUB_KEY_ID_0119000000012026051400000000000001")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_signature_message_preserves_raw_body_bytes() {
|
||||
let body = b"{\"message\":\"hello\\r\\nworld\"}\r\n";
|
||||
let message = build_notify_signature_message(b"1778759600", b"nonce-1", body);
|
||||
|
||||
assert_eq!(
|
||||
message,
|
||||
b"1778759600\nnonce-1\n{\"message\":\"hello\\r\\nworld\"}\r\n\n".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mock_notify_defaults_success_state() {
|
||||
let notify =
|
||||
|
||||
Reference in New Issue
Block a user