// src/alipay.rs — 支付宝 RSA2 签名与请求封装 use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, LineEnding}; use rsa::signature::Signer; use rsa::RsaPublicKey; use rsa::{Pkcs1v15Sign, RsaPrivateKey}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::env; /// 支付宝配置(从环境变量读取) pub struct AlipayConfig { pub app_id: String, pub private_key: String, pub alipay_public_key: String, pub gateway: String, } impl AlipayConfig { pub fn from_env() -> Option { let app_id = env::var("ALIPAY_APP_ID").ok()?; let private_key = env::var("ALIPAY_PRIVATE_KEY").ok()?; let alipay_public_key = env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok()?; let gateway = env::var("ALIPAY_GATEWAY") .unwrap_or_else(|_| "https://openapi.alipay.com/gateway.do".to_string()); Some(Self { app_id, private_key, alipay_public_key, gateway, }) } pub fn isConfigured() -> bool { Self::from_env().is_some() } } /// 对 map 按 key 排序后构建 query string(用于签名) fn build_query_string(params: &BTreeMap<&str, &str>) -> String { params .iter() .filter(|(_, v)| !v.is_empty()) .map(|(k, v)| format!("{}={}", k, urlencoding(v))) .collect::>() .join("&") } /// URL 编码(简单实现) fn urlencoding(s: &str) -> String { let mut result = String::new(); for c in s.chars() { match c { 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), _ => { for b in c.to_string().as_bytes() { result.push_str(&format!("%{:02X}", b)); } } } } result } /// 使用 RSA2 (SHA256) 对内容签名 pub fn rsa2_sign(content: &str, private_key_pem: &str) -> Result { // 解析 PKCS8 格式的私钥 // 支持两种格式: // 1. PEM 格式字符串 (-----BEGIN PRIVATE KEY-----...) // 2. Base64 编码的 PEM 字符串 (用于环境变量配置) let private_key = match RsaPrivateKey::from_pkcs8_pem(private_key_pem) { Ok(key) => key, Err(_) => { // 尝试作为 base64 编码的 PEM 解码 let decoded = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, private_key_pem, ) .map_err(|e| format!("Base64 解码失败: {}", e))?; let pem_str = String::from_utf8(decoded) .map_err(|e| format!("PEM 字符串格式错误: {}", e))?; RsaPrivateKey::from_pkcs8_pem(&pem_str) .map_err(|e| format!("私钥解析失败: {}", e))? } }; let signing_input = Sha256::digest(content.as_bytes()); let signature = private_key .sign(Pkcs1v15Sign::new::(), &signing_input) .map_err(|e| format!("签名失败: {}", e))?; Ok(base64::Engine::encode( &base64::engine::general_purpose::STANDARD, &signature, )) } /// 验证 RSA2 签名 pub fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result { let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) .map_err(|e| format!("支付宝公钥解析失败: {}", e))?; let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sign) .map_err(|e| format!("签名 Base64 解码失败: {}", e))?; let hashed_content = Sha256::digest(content.as_bytes()); Ok(public_key .verify( Pkcs1v15Sign::new::(), &hashed_content, &sig_bytes, ) .is_ok()) } /// 构建支付宝请求 URL(含签名) /// 返回 (url, sign),sign 已 URL 编码 pub fn build_signed_request( config: &AlipayConfig, biz_content: &str, other_params: Option>, ) -> Result<(String, String), String> { let mut params: BTreeMap<&str, &str> = BTreeMap::new(); params.insert("app_id", &config.app_id); params.insert("method", "alipay.trade.page.pay"); params.insert("format", "JSON"); params.insert("charset", "utf-8"); params.insert("sign_type", "RSA2"); params.insert( "timestamp", &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), ); params.insert("version", "1.0"); params.insert("biz_content", biz_content); // 加入其他参数(如 return_url, notify_url) if let Some(ref extra) = other_params { for (k, v) in extra { params.insert(k, v); } } // 按 RFC 3986 编码后拼接待签名内容 let sign_source: String = params .iter() .map(|(k, v)| format!("{}={}", k, urlencoding(v))) .collect::>() .join("&"); let sign = rsa2_sign(&sign_source, &config.private_key)?; // 构建最终 URL let query = build_query_string(¶ms) + "&sign=" + &urlencoding(&sign); let url = config.gateway.clone() + "?" + &query; Ok((url, sign)) } /// 调用支付宝接口并解析响应 pub async fn call_alipay( config: &AlipayConfig, biz_content: &str, other_params: Option>, ) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| format!("HTTP 客户端创建失败: {}", e))?; let mut params: BTreeMap<&str, &str> = BTreeMap::new(); params.insert("app_id", &config.app_id); params.insert("method", "alipay.trade.page.pay"); params.insert("format", "JSON"); params.insert("charset", "utf-8"); params.insert("sign_type", "RSA2"); params.insert( "timestamp", &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), ); params.insert("version", "1.0"); params.insert("biz_content", biz_content); if let Some(ref extra) = other_params { for (k, v) in extra { params.insert(k, v); } } let sign_source: String = params .iter() .map(|(k, v)| format!("{}={}", k, urlencoding(v))) .collect::>() .join("&"); let sign = rsa2_sign(&sign_source, &config.private_key)?; let query = build_query_string(¶ms) + "&sign=" + &urlencoding(&sign); let url = config.gateway.clone(); let resp = client .post(&url) .header("Content-Type", "application/x-www-form-urlencoded") .body(query) .send() .await .map_err(|e| format!("请求支付宝失败: {}", e))?; let body = resp.text().await.map_err(|e| format!("读取响应失败: {}", e))?; // 支付宝返回格式: alipay_trade_page_pay_response={...}&sign=xxx let parts: Vec<&str> = body.splitn(2, "&sign=").collect(); if parts.len() != 2 { return Err(format!("支付宝响应格式异常: {}", body)); } let json_str = parts[0] .strip_prefix("alipay_trade_page_pay_response=") .unwrap_or(parts[0]); let sign_from_alipay = parts[1]; // 验签(确保响应来自支付宝) if !rsa2_verify(json_str, sign_from_alipay, &config.alipay_public_key)? { return Err("支付宝响应验签失败".to_string()); } let json: serde_json::Value = serde_json::from_str(json_str).map_err(|e| format!("JSON 解析失败: {}", e))?; if json.get("code").and_then(|v| v.as_str()) != Some("10000") { let sub_msg = json.get("sub_msg").and_then(|v| v.as_str()).unwrap_or(""); return Err(format!( "支付宝接口错误: {} - {}", json.get("msg").and_then(|v| v.as_str()).unwrap_or("未知"), sub_msg )); } Ok(json) } #[cfg(test)] mod tests { use super::*; #[test] fn test_url_encoding() { assert_eq!(urlencoding("hello"), "hello"); assert_eq!(urlencoding("hello world"), "hello%20world"); assert_eq!(urlencoding("中文"), "%E4%B8%AD%E6%96%87"); } }