diff --git a/Cargo.lock b/Cargo.lock index b452df1..d01efc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1952,6 +1952,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2", "signature", "spki", "subtle", @@ -1966,15 +1967,20 @@ dependencies = [ "actix-web", "base64", "chrono", + "digest", "dotenvy", "error", + "hex", "include_dir", "jsonwebtoken", "log", "openssl", + "pkcs8", "reqwest", + "rsa", "serde", "serde_json", + "sha2", "sqlx", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index fda786a..7678737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,11 @@ uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["full"] } toml = "0.8" base64 = "0.22" +rsa = { version = "0.9", features = ["pem", "sha2"] } +pkcs8 = "0.10" +sha2 = "0.10" +hex = "0.4" +digest = "0.10" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/src/db.rs b/src/db.rs index 3570c75..cc52d29 100644 --- a/src/db.rs +++ b/src/db.rs @@ -392,6 +392,49 @@ pub async fn confirm_payment_order( Ok(expires_at) } +/// 确认订单支付(支付宝异步回调用,通过 order_no 查找,不校验 user_id) +pub async fn confirm_payment_order_by_orderno( + pool: &PgPool, + order_no: &str, +) -> Result<(), AppError> { + let row = sqlx::query_as::<_, (i32, String, Option>)>( + r#"SELECT user_id, status, expires_at FROM payment_orders WHERE order_no = $1"#, + ) + .bind(order_no) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?; + + let (user_id, status, expires_at) = match row { + Some(r) => r, + None => return Err(AppError::NotFound("订单不存在".to_string())), + }; + + if status != "pending" { + // 已支付直接返回成功(幂等) + return Ok(()); + } + + let mut tx = pool.begin().await.map_err(|e| AppError::Database(e.to_string()))?; + + sqlx::query(r#"UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1"#) + .bind(order_no) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Database(format!("更新订单状态失败: {}", e)))?; + + sqlx::query(r#"UPDATE users SET is_paid = true, paid_expires_at = $1 WHERE id = $2"#) + .bind(expires_at) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Database(format!("更新用户付费状态失败: {}", e)))?; + + tx.commit().await.map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) +} + /// 获取用户配额信息 /// /// 返回 (已用条数, 是否付费活跃, 到期时间) diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index fe98268..4e4401d 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -31,9 +31,11 @@ pub use weather::get_weather_brief; pub use weather::get_weather_details; pub use weather::post_weather_data; -pub use payment::alipay_trade_page_pay; +pub use payment::alipay_notify; +pub use payment::alipay_pay_page; pub use payment::create_order; pub use payment::get_user_quota; pub use payment::mock_confirm; pub use payment::payment_index; pub use payment::payment_page; +pub use payment::payment_success; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 30b0dab..4a1a7d8 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -1,13 +1,20 @@ // handlers/payment.rs — 支付相关处理器(接入支付宝) use actix_web::{get, post, web, HttpRequest, HttpResponse}; use chrono::Utc; +use rsa::pkcs8::DecodePrivateKey; +use rsa::signature::Signer; +use rsa::{Pkcs1v15Sign, RsaPrivateKey}; +use serde::Deserialize; +use sha2::Sha256; use sqlx::postgres::PgPool; +use std::collections::BTreeMap; use uuid::Uuid; use crate::db; use crate::error::AppError; -use crate::models::{AppState, Claims, CreateOrderRequest, MockConfirmRequest}; +use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest}; +// ===== 套餐定义 ===== struct PackageInfo { amount: i32, @@ -20,19 +27,19 @@ fn get_package_info(package_type: &str) -> Option { match package_type { "monthly" => Some(PackageInfo { amount: 990, - display_amount: "¥9.9", + display_amount: "9.9", display_name: "包月会员", days: Some(30), }), "yearly" => Some(PackageInfo { amount: 5900, - display_amount: "¥59", + display_amount: "59", display_name: "包年会员", days: Some(365), }), "permanent" => Some(PackageInfo { amount: 19900, - display_amount: "¥199", + display_amount: "199", display_name: "永久会员", days: None, }), @@ -40,10 +47,201 @@ fn get_package_info(package_type: &str) -> Option { } } -/// GET /payment — 套餐选择页面(无需认证,外部浏览器打开) +// ===== 支付宝配置 ===== + +struct AlipayConfig { + app_id: String, + private_key: String, + alipay_public_key: String, + gateway: String, +} + +impl AlipayConfig { + fn from_env() -> Option { + Some(Self { + app_id: std::env::var("ALIPAY_APP_ID").ok()?, + private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok()?, + alipay_public_key: std::env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok()?, + gateway: std::env::var("ALIPAY_GATEWAY") + .unwrap_or_else(|_| "https://openapi.alipay.com/gateway.do".to_string()), + }) + } +} + +/// URL 编码(RFC 3986) +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) 签名 +fn rsa2_sign(content: &str, private_key_pem: &str) -> Result { + let private_key = + RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| format!("私钥解析失败: {}", e))?; + let signature = private_key.sign(Pkcs1v15Sign::new::(), content.as_bytes()) + .map_err(|e| format!("签名失败: {}", e))?; + Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &signature, + )) +} + +/// 验证 RSA2 签名 +fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result { + use rsa::pkcs8::DecodePublicKey; + let public_key = + rsa::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))?; + Ok(public_key + .verify(Pkcs1v15Sign::new::(), content.as_bytes(), &sig_bytes) + .is_ok()) +} + +// ===== Helper: 调用 alipay.trade.page.pay ===== + +async fn call_alipay_trade_page_pay( + config: &AlipayConfig, + out_trade_no: &str, + total_amount: &str, + subject: &str, + notify_url: &str, + return_url: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP 客户端创建失败: {}", e))?; + + let biz_content = serde_json::json!({ + "out_trade_no": out_trade_no, + "total_amount": total_amount, + "subject": subject, + "product_code": "FAST_INSTANT_TRADE_PAY", + }); + + let biz_content_str = + serde_json::to_string(&biz_content).map_err(|e| format!("biz_content 序列化失败: {}", e))?; + + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_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", ×tamp); + params.insert("version", "1.0"); + params.insert("biz_content", &biz_content_str); + params.insert("notify_url", notify_url); + params.insert("return_url", return_url); + + // 构造待签名串 + let sign_source: String = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding(v))) + .collect::>() + .join("&"); + + let sign = rsa2_sign(&sign_source, &config.private_key)?; + + // 构建 POST body + let query: String = params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v))) + .chain(std::iter::once(format!("sign={}", urlencoding(&sign)))) + .collect::>() + .join("&"); + + let resp = client + .post(&config.gateway) + .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 { + // 如果直接返回 HTML 表单(沙箱环境可能直接返回表单),直接返回 + if body.contains(" Option { + req.headers() + .get("Authorization")? + .to_str() + .ok()? + .strip_prefix("Bearer ") + .map(|s| s.to_string()) +} + +fn get_jwt_secret() -> String { + std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()) +} + +// ===== Handler: GET /payment — 套餐选择页 ===== + #[get("/payment")] pub async fn payment_index() -> Result { - let html = r#" + let html = r##" @@ -51,137 +249,28 @@ pub async fn payment_index() -> Result { 开通会员 - 大气稳定度判定 @@ -189,7 +278,6 @@ pub async fn payment_index() -> Result {

开通会员

解锁无限检测额度,畅享全部功能

-
推荐
@@ -199,10 +287,8 @@ pub async fn payment_index() -> Result {
每月 500 次检测额度
查看完整历史记录
-
专属客服支持
-
包年会员
¥59/年
@@ -210,33 +296,24 @@ pub async fn payment_index() -> Result {
每年 5000 次检测额度
查看完整历史记录
-
专属客服支持
- -
-
超值
+
+
超值
永久会员
-
¥199/终身
+
¥199/终身
一次购买,终身享用
无限次检测额度
查看完整历史记录
-
永久专属客服支持
优先体验新功能
- - -
- 支付成功后额度将自动到账,如有疑问请联系客服 -
- +
支付成功后额度将自动到账,如有疑问请联系客服
-"#; +"##; Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html)) } -// ===== 支付宝配置 ===== -// -// 请在 config/development.toml 或 config/production.toml 中配置以下字段: -// alipay_app_id = "your_alipay_app_id" -// alipay_private_key = "your_private_key_content" -// alipay_alipay_public_key = "alipay_public_key_content" -// alipay_gateway = "https://openapi-sandbox.dl.alipaydev.com/gateway.do" # 沙箱 -// alipay_gateway = "https://openapi.alipay.com/gateway.do" # 正式 -// +// ===== Handler: GET /payment/page — 创建订单并跳转支付宝 ===== -/// 提取 JWT token(供支付页面使用) -fn extract_token(req: &HttpRequest) -> Option { - req.headers() - .get("Authorization")? - .to_str() - .ok()? - .strip_prefix("Bearer ") - .map(|s| s.to_string()) +#[derive(Debug, Deserialize)] +pub struct PaymentPageQuery { + #[serde(rename = "package")] + pub package_: String, } -/// GET /payment/page?package=xxx — 返回支付引导页面(用户扫码/浏览器打开) #[get("/payment/page")] pub async fn payment_page( req: HttpRequest, pool: web::Data, - app_state: web::Data, query: web::Query, ) -> Result { let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; - // 验证 token - let claims = crate::auth::verify_token(&token, &app_state.jwt_secret) + let claims = crate::auth::verify_token(&token, &get_jwt_secret()) .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; let pkg = get_package_info(&query.package_) .ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?; - // 生成订单号 let order_no = Uuid::new_v4().to_string(); let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d)); - // 创建待支付订单 db::create_payment_order( pool.get_ref(), claims.user_id, @@ -314,127 +372,212 @@ pub async fn payment_page( ) .await?; - // 构造支付页面 URL(用户打开后展示支付宝收款码) - // 实际跳转地址由前端 outter 页面处理 let base_url = std::env::var("APP_BASE_URL") - .unwrap_or_else(|_| "https://xmclassmate.top".to_string()); - let _payment_url = format!( - "{}/payment/pay?order_no={}&package_type={}", - base_url, order_no, query.package_ - ); + .unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string()); + let notify_url = format!("{}/payment/notify", base_url); + let return_url = format!("{}/payment/success?order_no={}", base_url, order_no); - let html = format!( - r#" - - - - -支付页面 - - - -
-
订单待支付
-
{display_amount}
-
{display_name}
-
即将跳转至支付宝支付页面
- - 打开支付宝支付 - -
订单号: {order_no}
-
- - -"#, - display_amount = pkg.display_amount, - display_name = pkg.display_name, - order_no = order_no, - package_type = query.package_, - ); + let Some(config) = AlipayConfig::from_env() else { + return Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(format_error_html("支付配置不完整,请联系管理员", &order_no))); + }; - Ok(HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html)) + let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0); + + match call_alipay_trade_page_pay( + &config, + &order_no, + &total_amount_str, + pkg.display_name, + ¬ify_url, + &return_url, + ) + .await + { + Ok(form_html) => Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(form_html)), + Err(e) => { + tracing::error!("支付宝下单失败: {}", e); + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(format_error_html(&e, &order_no))) + } + } +} + +// ===== Handler: GET /payment/pay — 直接支付接口 ===== + +#[derive(Debug, Deserialize)] +pub struct AlipayPayQuery { + pub order_no: String, + pub package_type: String, } -/// GET /payment/pay — 支付宝电脑网站支付(返回支付表单或链接) #[get("/payment/pay")] -pub async fn alipay_trade_page_pay( +pub async fn alipay_pay_page( req: HttpRequest, - app_state: web::Data, query: web::Query, ) -> Result { let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; - let _claims = crate::auth::verify_token(&token, &app_state.jwt_secret) + crate::auth::verify_token(&token, &get_jwt_secret()) .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; - // TODO: 调用 alipay.trade.page.pay 接口 - // 当前为占位实现,正式接入时替换为支付宝 SDK 调用 + let pkg = get_package_info(&query.package_type) + .ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?; + + let base_url = std::env::var("APP_BASE_URL") + .unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string()); + let notify_url = format!("{}/payment/notify", base_url); + let return_url = format!("{}/payment/success?order_no={}", base_url, query.order_no); + + let Some(config) = AlipayConfig::from_env() else { + return Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(format_error_html("支付配置不完整,请联系管理员", &query.order_no))); + }; + + let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0); + + match call_alipay_trade_page_pay( + &config, + &query.order_no, + &total_amount_str, + pkg.display_name, + ¬ify_url, + &return_url, + ) + .await + { + Ok(form_html) => Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(form_html)), + Err(e) => { + tracing::error!("支付宝下单失败: {}", e); + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(format_error_html(&e, &query.order_no))) + } + } +} + +// ===== Handler: POST /payment/notify — 支付宝异步回调 ===== + +#[derive(Debug, Deserialize)] +pub struct AlipayNotify { + pub out_trade_no: String, + pub trade_no: String, + pub trade_status: String, + pub total_amount: Option, + pub app_id: Option, + pub sign: Option, +} + +#[post("/payment/notify")] +pub async fn alipay_notify( + pool: web::Data, + body: web::Form, +) -> HttpResponse { + let body = body.into_inner(); + + tracing::info!( + "收到支付宝回调: out_trade_no={}, trade_status={}", + body.out_trade_no, + body.trade_status + ); + + // 1. 检查交易状态 + if body.trade_status != "TRADE_SUCCESS" && body.trade_status != "TRADE_FINISHED" { + return HttpResponse::Ok().body("success"); + } + + // 2. 确认订单(通过 order_no,不校验 user_id) + match db::confirm_payment_order_by_orderno(pool.get_ref(), &body.out_trade_no).await { + Ok(_) => { + tracing::info!("订单 {} 支付确认成功", body.out_trade_no); + HttpResponse::Ok().body("success") + } + Err(e) => { + tracing::error!("订单 {} 确认失败: {}", body.out_trade_no, e); + HttpResponse::Ok().body("fail") + } + } +} + +// ===== Handler: GET /payment/success — 支付成功页面 ===== + +#[derive(Debug, Deserialize)] +pub struct AlipaySuccessQuery { + pub order_no: Option, +} + +fn build_success_html(order_no: &str) -> String { + let green = "#52c41a"; + let white = "white"; let html = format!( - r#" + r##" -支付宝支付 +支付成功
- -
支付宝收款页面
-
订单号:{order_no}
-
套餐:{package_type}
-
- 支付宝支付表单将在这里渲染
- (需配置 alipay_app_id 和私钥) -
-
⚠️ 请在 .env 或配置文件中设置支付宝相关配置
-
- 支付完成后页面将自动跳转
- 如未跳转,请手动关闭此页面 -
+ + + + +

支付成功!

+

恭喜您已成为会员,额度已自动到账

+
请返回微信小程序查看您的会员状态
+
订单号: {2}
-"#, - order_no = query.order_no, - package_type = query.package_type, +"##, + green, white, order_no ); - - Ok(HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html)) + html } +fn format_error_html(msg: &str, order_no: &str) -> String { + format!( + r##" + +支付失败 + +

支付页面生成失败

+

{}

+

订单号: {}

+

返回重试

+ +"##, + msg, order_no + ) +} + +#[get("/payment/success")] +pub async fn payment_success(query: web::Query) -> HttpResponse { + let order_no = query.order_no.as_deref().unwrap_or(""); + let html = build_success_html(order_no); + + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) +} + +// ===== 旧的 API Handler(保持兼容)===== + /// POST /api/payment/create-order #[post("/api/payment/create-order")] pub async fn create_order( @@ -486,7 +629,8 @@ pub async fn mock_confirm( ) -> Result { let user_id = claims.user_id; - let expires_at = db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await?; + let expires_at = + db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, @@ -505,7 +649,8 @@ pub async fn get_user_quota( ) -> Result { let user_id = claims.user_id; - let (used, is_paid_active, paid_expires_at) = db::get_user_quota(pool.get_ref(), user_id).await?; + let (used, is_paid_active, paid_expires_at) = + db::get_user_quota(pool.get_ref(), user_id).await?; let limit: i64 = std::env::var("FREE_USER_DATA_LIMIT") .ok() .and_then(|v| v.parse().ok()) @@ -522,18 +667,3 @@ pub async fn get_user_quota( } }))) } - -// ===== Query Structs ===== - -#[derive(Debug, serde::Deserialize)] -pub struct PaymentPageQuery { - #[serde(rename = "package")] - pub package_: String, -} - -#[derive(Debug, serde::Deserialize)] -pub struct AlipayPayQuery { - pub order_no: String, - #[serde(rename = "package_type")] - pub package_type: String, -} diff --git a/src/main.rs b/src/main.rs index 98f93cb..93acbc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,10 +17,11 @@ use auth::jwt_middleware; use config::AppConfig; use db::create_pool; use handlers::{ - admin_get_user, admin_update_user_payment, add_favorite, alipay_trade_page_pay, + admin_get_user, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page, create_order, delete_weather, generate_temp_token_handler, get_current_user_profile, get_favorites, get_user_quota, get_weather_brief, get_weather_details, - health_check, login, mock_confirm, payment_index, payment_page, post_weather_data, + health_check, login, mock_confirm, payment_index, payment_page, payment_success, + post_weather_data, refresh_token, remove_favorite, root, save_user_profile, serve_static_files, }; use models::AppState; @@ -64,7 +65,9 @@ fn create_server_config( // 支付页面(无需认证,外部浏览器访问) .service(payment_index) // GET /payment — 套餐选择页 .service(payment_page) // GET /payment/page(需 JWT) - .service(alipay_trade_page_pay) // GET /payment/pay(需 JWT) + .service(payment_success) + .service(alipay_pay_page) // GET /payment/pay(需 JWT) + .service(alipay_notify) // POST /payment/notify(支付宝异步回调) // 静态文件(无需认证) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) // API 接口