fix: enforce WeChat Pay JSAPI field limits
This commit is contained in:
@@ -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(¬ify_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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user