fix wechat pay request headers
This commit is contained in:
@@ -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` 的类型和状态都不可读。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
关键环境变量:
|
关键环境变量:
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user