Files
asd-backend/src/alipay.rs
Milky0217 00fea53a4c fix: 修复支付宝 RSA 签名失败问题
- 支持 base64 编码的 PEM 格式私钥(用于 systemd 环境变量)
- RSA 签名/验证前需先用 Sha256::digest() 哈希输入
- 添加 Digest trait 导入
2026-04-27 13:53:18 +08:00

261 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(&params)
+ "&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(&params)
+ "&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");
}
}