- 支持 base64 编码的 PEM 格式私钥(用于 systemd 环境变量) - RSA 签名/验证前需先用 Sha256::digest() 哈希输入 - 添加 Digest trait 导入
261 lines
8.0 KiB
Rust
261 lines
8.0 KiB
Rust
// 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<Self> {
|
||
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::<Vec<_>>()
|
||
.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<String, String> {
|
||
// 解析 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::<Sha256>(), &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<bool, String> {
|
||
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::<Sha256>(),
|
||
&hashed_content,
|
||
&sig_bytes,
|
||
)
|
||
.is_ok())
|
||
}
|
||
|
||
/// 构建支付宝请求 URL(含签名)
|
||
/// 返回 (url, sign),sign 已 URL 编码
|
||
pub fn build_signed_request(
|
||
config: &AlipayConfig,
|
||
biz_content: &str,
|
||
other_params: Option<BTreeMap<&str, &str>>,
|
||
) -> 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::<Vec<_>>()
|
||
.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<BTreeMap<&str, &str>>,
|
||
) -> Result<serde_json::Value, String> {
|
||
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::<Vec<_>>()
|
||
.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");
|
||
}
|
||
}
|