From bca439726d1c2c24870e6f617d9a088f5458088a Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 14 May 2026 20:51:32 +0800 Subject: [PATCH] fix wechat pay request headers --- .hermes/shared-memory/pitfalls.md | 9 +++ ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 2 + server-rs/crates/api-server/src/wechat_pay.rs | 77 +++++++++++++++---- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 208b1761..e321b193 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -619,9 +619,18 @@ - 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。 - 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。 - 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*` 与 `WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id` 到 `profile_recharge_order.provider_transaction_id`。 +- APIv3 通知成功应答使用 HTTP `204 No Content`,不要沿用 V2 XML 成功报文;失败仍返回 4XX/5XX 让微信重试。 - 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。 - 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 +## 微信支付 JSAPI 下单必须显式带 User-Agent + +- 现象:调用 `/v3/pay/transactions/jsapi` 失败,微信返回“Http头缺少Accept或User-Agent”。 +- 原因:`reqwest` 请求即使已设置 `Accept: application/json`,也不会默认附带业务侧 `User-Agent`;微信支付网关会校验这两个头。 +- 处理:`api-server` 的 JSAPI 下单请求统一通过 `with_wechat_pay_jsapi_headers(...)` 设置 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`。 +- 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 + ## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码 - 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index b19f64ff..987b0df8 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -65,6 +65,7 @@ 1. 校验 `productId` 2. `paymentChannel = "mock"` 时后端创建已支付订单 3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 + - JSAPI 下单请求必须显式携带 `Accept: application/json`、`Content-Type: application/json` 和 `User-Agent: Genarrative-WechatPay/1.0`;微信侧会把缺少 `User-Agent` 的请求返回为“Http头缺少Accept或User-Agent”。 4. mock 泥点套餐立即写入钱包余额与流水,mock 会员套餐立即写入会员状态 5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` @@ -94,6 +95,7 @@ 4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`。 5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。 6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。 +7. 验签、解密和业务确认通过后返回 HTTP `204 No Content`;不要返回 V2 XML。 关键环境变量: diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index aca3825e..e14532e5 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -25,7 +25,9 @@ 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 = ""; +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_APP_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; @@ -277,18 +279,17 @@ 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), + ) + .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}")) @@ -438,7 +439,7 @@ pub async fn handle_wechat_pay_notify( State(state): State, headers: HeaderMap, body: Bytes, -) -> Result<&'static str, AppError> { +) -> Result { let notify = state .wechat_pay_client() .parse_notify(&headers, &body) @@ -449,7 +450,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 @@ -476,7 +477,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 { @@ -532,6 +533,16 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError { map_wechat_pay_error(error) } +fn with_wechat_pay_jsapi_headers(builder: reqwest::RequestBuilder) -> 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) +} + 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(); @@ -931,6 +942,40 @@ mod tests { ); } + #[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\"", + ), + ) + .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) + ); + } + #[test] fn parse_mock_notify_defaults_success_state() { let notify =