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 =