fix: enforce WeChat Pay JSAPI field limits

This commit is contained in:
2026-05-14 20:22:49 +08:00
parent 514365fdec
commit 5c5a8d4a40
4 changed files with 213 additions and 9 deletions

View File

@@ -64,7 +64,7 @@
1. 校验 `productId`
2. `paymentChannel = "mock"` 时后端创建已支付订单
3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数
3. `paymentChannel = "wechat_mp"` 时后端创建待支付订单,并调用微信支付 JSAPI 下单生成小程序支付参数;本地 `orderId` 会作为微信 `out_trade_no` 传递,格式固定为 `rcg` 前缀 + 小写字母数字,长度在 6-32 字符内,满足微信支付 JSAPI 下单文档对商户订单号的限制。商品描述限制为 127 字符内,回调地址限制为 HTTPS、255 字符内且不携带 query/fragment。
4. mock 泥点套餐立即写入钱包余额与流水mock 会员套餐立即写入会员状态
5. wechat_mp 订单不提前发泥点或会员,只返回待支付订单、账户中心快照与 `wechatMiniProgramPayParams`

View File

@@ -26,6 +26,12 @@ 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_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 {
@@ -77,7 +83,6 @@ pub enum WechatPayError {
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WechatJsapiOrderRequest<'a> {
appid: &'a str,
mchid: &'a str,
@@ -196,6 +201,7 @@ impl WechatPayClient {
config.wechat_pay_notify_url.as_deref(),
"WECHAT_PAY_NOTIFY_URL",
)?;
validate_notify_url(&notify_url, "WECHAT_PAY_NOTIFY_URL")?;
let jsapi_endpoint = normalize_required_url(
&config.wechat_pay_jsapi_endpoint,
"WECHAT_PAY_JSAPI_ENDPOINT",
@@ -244,6 +250,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 {
@@ -595,6 +602,105 @@ 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 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>,
@@ -768,6 +874,63 @@ 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 parse_mock_notify_defaults_success_state() {
let notify =

View File

@@ -927,10 +927,47 @@ pub fn build_runtime_profile_recharge_order_id(
created_at_micros: i64,
product_id: &str,
) -> String {
format!(
"recharge:{}",
build_runtime_profile_recharge_wallet_ledger_id(user_id, created_at_micros, product_id)
)
// 微信支付 v3 的 out_trade_no 只接受较短的字母、数字和部分符号。
// 订单号同时作为本地 profile_recharge_order 主键,因此统一使用可支付渠道兼容的紧凑格式。
let timestamp = encode_runtime_profile_recharge_order_base36(created_at_micros.unsigned_abs());
let hash = hash_runtime_profile_recharge_order_key(user_id, product_id, created_at_micros);
format!("rcg{timestamp}{:010x}", hash & 0x0000_0003_ffff_ffff)
}
fn encode_runtime_profile_recharge_order_base36(mut value: u64) -> String {
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
if value == 0 {
return "0".to_string();
}
let mut buffer = Vec::new();
while value > 0 {
buffer.push(DIGITS[(value % 36) as usize] as char);
value /= 36;
}
buffer.iter().rev().collect()
}
fn hash_runtime_profile_recharge_order_key(
user_id: &str,
product_id: &str,
created_at_micros: i64,
) -> u64 {
let mut hash = 14_695_981_039_346_656_037u64;
for byte in user_id
.trim()
.as_bytes()
.iter()
.copied()
.chain([b':'])
.chain(product_id.trim().as_bytes().iter().copied())
.chain([b':'])
.chain(created_at_micros.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(1_099_511_628_211);
}
hash
}
pub fn resolve_runtime_profile_points_recharge_delta(

View File

@@ -683,9 +683,13 @@ mod tests {
build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"),
"user-1:200:points_60"
);
assert_eq!(
build_runtime_profile_recharge_order_id("user-1", 200, "points_60"),
"recharge:user-1:200:points_60"
let order_id = build_runtime_profile_recharge_order_id("user-1", 200, "points_60");
assert!(order_id.starts_with("rcg"));
assert!(order_id.len() <= 32);
assert!(
order_id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
);
assert_eq!(
build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2),