fix wechat pay request headers

This commit is contained in:
2026-05-14 20:51:32 +08:00
parent 5c5a8d4a40
commit bca439726d
3 changed files with 72 additions and 16 deletions

View File

@@ -619,9 +619,18 @@
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。 - 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。 - 原因:商户私钥只用于商户请求微信支付和生成小程序 `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` - 处理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 密钥验证签名与解密链路。 - 验证mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs``docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md` - 关联:`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 解码 ## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind``status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 - 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind``status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。

View File

@@ -65,6 +65,7 @@
1. 校验 `productId` 1. 校验 `productId`
2. `paymentChannel = "mock"` 时后端创建已支付订单 2. `paymentChannel = "mock"` 时后端创建已支付订单
3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。 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 会员套餐立即写入会员状态 4. mock 泥点套餐立即写入钱包余额与流水mock 会员套餐立即写入会员状态
5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams` 5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams`
@@ -94,6 +95,7 @@
4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid` 4. 使用微信通知里的 `out_trade_no` 查本地 `profile_recharge_order.order_id`,把订单从 `pending` 改为 `paid`
5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。 5. 将微信平台订单号写入 `provider_transaction_id`,用于对账、查单、退款和客服排障。
6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。 6. 在同一 SpacetimeDB procedure 内写入钱包流水或会员到期时间,确保重复通知幂等。
7. 验签、解密和业务确认通过后返回 HTTP `204 No Content`;不要返回 V2 XML。
关键环境变量: 关键环境变量:

View File

@@ -25,7 +25,9 @@ const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
const WECHAT_PAY_PROVIDER_REAL: &str = "real"; const WECHAT_PAY_PROVIDER_REAL: &str = "real";
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048"; const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA"; 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_APP_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32;
const WECHAT_PAY_MCH_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_DESCRIPTION_MAX_CHARS: usize = 127;
@@ -277,18 +279,17 @@ impl RealWechatPayClient {
&nonce, &nonce,
&body, &body,
)?; )?;
let response = self let response = with_wechat_pay_jsapi_headers(
.client self.client
.post(&self.jsapi_endpoint) .post(&self.jsapi_endpoint)
.header("Authorization", authorization) .header("Authorization", authorization),
.header("Accept", "application/json") )
.header("Content-Type", "application/json") .body(body)
.body(body) .send()
.send() .await
.await .map_err(|error| {
.map_err(|error| { WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}")) })?;
})?;
let status = response.status(); let status = response.status();
let response_text = response.text().await.map_err(|error| { let response_text = response.text().await.map_err(|error| {
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}")) WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
@@ -438,7 +439,7 @@ pub async fn handle_wechat_pay_notify(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> Result<&'static str, AppError> { ) -> Result<StatusCode, AppError> {
let notify = state let notify = state
.wechat_pay_client() .wechat_pay_client()
.parse_notify(&headers, &body) .parse_notify(&headers, &body)
@@ -449,7 +450,7 @@ pub async fn handle_wechat_pay_notify(
trade_state = notify.trade_state.as_str(), trade_state = notify.trade_state.as_str(),
"收到非成功微信支付通知" "收到非成功微信支付通知"
); );
return Ok(WECHAT_PAY_NOTIFY_SUCCESS); return Ok(StatusCode::NO_CONTENT);
} }
let paid_at_micros = notify 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 { 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) 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 { fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
let nonce_str = "mock-nonce".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] #[test]
fn parse_mock_notify_defaults_success_state() { fn parse_mock_notify_defaults_success_state() {
let notify = let notify =