// 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 tracing::info; use uuid::Uuid; use crate::db; use crate::error::AppError; use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest}; // ===== 套餐定义 ===== struct PackageInfo { amount: i32, display_amount: &'static str, display_name: &'static str, days: Option, } fn get_package_info(package_type: &str) -> Option { match package_type { "monthly" => Some(PackageInfo { amount: 990, display_amount: "9.9", display_name: "包月会员", days: Some(30), }), "yearly" => Some(PackageInfo { amount: 5900, display_amount: "59", display_name: "包年会员", days: Some(365), }), "permanent" => Some(PackageInfo { amount: 19900, display_amount: "199", display_name: "永久会员", days: None, }), _ => None, } } // ===== 支付宝配置 ===== 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##" 开通会员 - 大气稳定度判定
选择会员套餐
推荐
包月会员
¥9.9/月
适合短期使用需求
每月 500 次检测额度
查看完整历史记录
包年会员
¥59/年
相当于每月 ¥4.9,性价比最高
每年 5000 次检测额度
查看完整历史记录
超值
永久会员
¥199/终身
一次购买,终身享用
无限次检测额度
查看完整历史记录
优先体验新功能
支付成功后额度将自动到账
"##; Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html)) } // ===== Handler: GET /payment/page — 创建订单并跳转支付宝 ===== #[derive(Debug, Deserialize)] pub struct PaymentPageQuery { #[serde(rename = "package")] pub package_: String, /// JWT: 从 URL 参数传入(外部浏览器无 Cookie 时使用) #[serde(default)] pub jwt: Option, } #[get("/payment/page")] pub async fn payment_page( req: HttpRequest, pool: web::Data, query: web::Query, ) -> Result { let token = extract_token(&req) .or_else(|| query.jwt.clone()) .ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; 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, &order_no, &query.package_, pkg.amount, expires_at, ) .await?; 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, order_no); let Some(config) = AlipayConfig::from_env() else { return Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(format_error_html("支付配置不完整,请联系管理员", &order_no))); }; 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")] pub async fn alipay_pay_page( req: HttpRequest, query: web::Query, ) -> Result { let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; crate::auth::verify_token(&token, &get_jwt_secret()) .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; 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##" 支付成功

支付成功!

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

请返回微信小程序查看您的会员状态
订单号: {2}
"##, green, white, order_no ); 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( pool: web::Data, claims: web::ReqData, body: web::Json, ) -> Result { let user_id = claims.user_id; let pkg = match get_package_info(&body.package_type) { Some(p) => p, None => { return Err(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(), user_id, &order_no, &body.package_type, pkg.amount, expires_at, ) .await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "data": { "order_id": order_no, "package_type": body.package_type, "amount": pkg.amount, "display_amount": pkg.display_amount, "display_name": pkg.display_name, "expires_at": expires_at, } }))) } /// POST /api/payment/mock-confirm #[post("/api/payment/mock-confirm")] pub async fn mock_confirm( pool: web::Data, claims: web::ReqData, body: web::Json, ) -> Result { let user_id = claims.user_id; 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, "data": { "is_paid_active": true, "paid_expires_at": expires_at, } }))) } /// GET /api/user/quota #[get("/api/user/quota")] pub async fn get_user_quota( pool: web::Data, claims: web::ReqData, ) -> 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 limit: i64 = std::env::var("FREE_USER_DATA_LIMIT") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(20); Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "data": { "used": used, "limit": limit, "unlimited": is_paid_active, "is_paid_active": is_paid_active, "paid_expires_at": paid_expires_at, } }))) } /// GET /payment/login-status?code=ASD-XXXXX /// 网页端轮询:查询登录码是否已被小程序确认 #[derive(Debug, Deserialize)] pub struct LoginStatusQuery { pub code: String, } #[derive(Debug, serde::Serialize)] pub struct LoginStatusResponse { pub success: bool, pub confirmed: bool, pub token: Option, pub is_paid_active: bool, pub paid_expires_at: Option, } // ===== Handler: GET /payment/generate-code — 网页端生成登录码 ===== #[derive(Debug, serde::Serialize)] pub struct GenerateCodeResponse { pub code: String, pub expires_in: i64, // 秒 } /// 生成随机登录码(网页端专用,无需认证) #[get("/payment/generate-code")] pub async fn generate_code( pool: web::Data, ) -> Result { use rand::Rng; let mut rng = rand::thread_rng(); let suffix: String = (0..6) .map(|_| { let idx = rng.gen_range(0..36); if idx < 10 { (b'0' + idx) as char } else { (b'A' + idx - 10) as char } }) .collect(); let code = format!("ASD-{}", suffix); let expires_at = Utc::now() + chrono::Duration::minutes(10); sqlx::query("INSERT INTO web_login_codes (code, expires_at) VALUES ($1, $2)") .bind(&code) .bind(expires_at) .execute(pool.get_ref()) .await .map_err(|e| AppError::Internal(format!("建码失败: {}", e)))?; Ok(HttpResponse::Ok().json(GenerateCodeResponse { code, expires_in: 600, })) } #[get("/payment/login-status")] pub async fn payment_login_status( pool: web::Data, query: web::Query, app_state: web::Data, ) -> Result { let code = query.code.trim(); // 查询登录码记录(包含 token 字段用于判断是否已确认) let record: Option<(String, chrono::DateTime, Option, Option)> = sqlx::query_as( "SELECT code, expires_at, token, user_id FROM web_login_codes WHERE code = $1", ) .bind(code) .fetch_optional(pool.get_ref()) .await .map_err(|e| AppError::Internal(format!("数据库查询失败: {}", e)))?; let (db_code, expires_at, token, user_id) = match record { Some(r) => r, None => { return Ok(HttpResponse::Ok().json(LoginStatusResponse { success: false, confirmed: false, token: None, is_paid_active: false, paid_expires_at: None, })); } }; // 检查是否过期 if Utc::now() > expires_at { let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") .bind(&db_code) .execute(pool.get_ref()) .await; return Ok(HttpResponse::Ok().json(LoginStatusResponse { success: false, confirmed: false, token: None, is_paid_active: false, paid_expires_at: None, })); } // 登录码存在但还没被小程序确认(token 为空 = 刚生成,还没点确认) // 使用 token 字段判断是否已确认(而非 user_id,因为 web_generate_login_code 会设置 user_id) if token.is_none() { return Ok(HttpResponse::Ok().json(LoginStatusResponse { success: true, confirmed: false, token: None, is_paid_active: false, paid_expires_at: None, })); } // 已确认 → 使用已生成的 token let token = token.unwrap(); let user_id = user_id.unwrap(); let (is_paid_active, paid_expires_at): (bool, Option) = match sqlx::query_as::<_, (bool, Option>)>("SELECT is_paid_active($1)") .bind(user_id) .fetch_optional(pool.get_ref()) .await { Ok(Some((active, expires))) => (active, expires.map(|e| e.to_rfc3339())), _ => (false, None), }; // 清理已使用的登录码 let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") .bind(&db_code) .execute(pool.get_ref()) .await; info!("[LOGIN STATUS] user_id={} confirmed=true", user_id); Ok(HttpResponse::Ok().json(LoginStatusResponse { success: true, confirmed: true, token: Some(token), is_paid_active, paid_expires_at, })) }