// handlers/payment.rs — 支付相关处理器(接入支付宝) use actix_web::{get, post, web, HttpRequest, HttpResponse}; use chrono::Utc; use rsa::pkcs1v15::SigningKey; use rsa::pkcs8::DecodePrivateKey; use rsa::signature::{SignatureEncoding, Signer}; use rsa::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, original_amount: &'static str, display_name: &'static str, days: Option, } fn get_package_info(package_type: &str) -> Option { match package_type { "monthly" => Some(PackageInfo { amount: 590, display_amount: "5.9", original_amount: "9.9", display_name: "包月会员", days: Some(30), }), "quarterly" => Some(PackageInfo { amount: 1680, display_amount: "16.8", original_amount: "29", display_name: "季卡会员", days: Some(90), }), "half_year" => Some(PackageInfo { amount: 3190, display_amount: "31.9", original_amount: "49", display_name: "半年会员", days: Some(182), }), "yearly" => Some(PackageInfo { amount: 6020, display_amount: "60.2", original_amount: "99", display_name: "包年会员", days: Some(365), }), _ => 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 = match RsaPrivateKey::from_pkcs8_pem(private_key_pem) { Ok(key) => key, Err(_) => { let decoded = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, private_key_pem, ) .map_err(|e| format!("Base64 解码失败: {}", e))?; // 支付宝密钥工具生成的 Base64 解码后是 PKCS#8 DER,不是 PEM 文本 RsaPrivateKey::from_pkcs8_der(&decoded) .map_err(|e| format!("私钥解析失败: {}", e))? } }; let signing_key = SigningKey::::new(private_key); let signature = signing_key.sign(content.as_bytes()); Ok(base64::Engine::encode( &base64::engine::general_purpose::STANDARD, signature.to_bytes().as_ref(), )) } /// 合并 mock 支付 HTML(在 payment_page 和 alipay_pay_page 中复用) fn build_mock_pay_html(order_no: &str, display_name: &str, jwt: &str) -> String { format!(r#" 模拟支付

模拟支付环境

当前为沙箱模拟环境,无需真实支付

订单号: {}

套餐: {}

返回重试

"#, order_no, display_name, jwt, order_no) } /// 验证 RSA2 签名 fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result { use rsa::pkcs8::DecodePublicKey; use rsa::RsaPublicKey; use sha2::Digest; 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 = Sha256::digest(content.as_bytes()); public_key .verify(rsa::Pkcs1v15Sign::new::(), &hashed, &sig_bytes) .map_err(|e| format!("签名验证失败: {}", e))?; Ok(true) } // ===== Helper: 生成支付宝支付表单 HTML ===== fn build_alipay_form_html( config: &AlipayConfig, out_trade_no: &str, total_amount: &str, subject: &str, notify_url: &str, return_url: &str, ) -> Result { 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); // 构造待签名串(注意:签名源使用原始值,不做 URL 编码) let sign_source: String = params .iter() .map(|(k, v)| format!("{}={}", k, *v)) .collect::>() .join("&"); let sign = rsa2_sign(&sign_source, &config.private_key)?; // 生成自动提交的表单 HTML(HTML 转义防止特殊字符破坏属性) fn h(s: &str) -> String { s.replace('&', "&") .replace('"', """) .replace('<', "<") .replace('>', ">") } let mut form_fields = String::new(); for (k, v) in ¶ms { form_fields.push_str(&format!( r#""#, h(k), h(v) )); } form_fields.push_str(&format!( r#""#, h(&sign) )); let html = format!( r##" 正在跳转支付宝...
{}
"##, config.gateway, form_fields ); Ok(html) } // ===== 提取 JWT token ===== /// 维护模式守卫:PAYMENT_MAINTENANCE_MODE=true 时所有支付接口返回 503 fn check_payment_maintenance() -> Result<(), AppError> { if std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()) { return Err(AppError::ServiceUnavailable( "支付系统维护中,请稍后再试".to_string() )); } Ok(()) } fn extract_token(req: &HttpRequest) -> 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").expect("JWT_SECRET must be set") } // ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) ===== #[get("/payment")] pub async fn payment_index() -> Result { check_payment_maintenance()?; let html = r##" 开通会员 - 环境计算助手
选择会员套餐
包月会员
¥9.9 ¥5.9/月
适合短期使用需求
每月 500 条存储记录
查看完整历史记录
季卡会员
¥29 ¥16.8/季
适合中期使用
每季 500 条存储记录
查看完整历史记录
半年会员
¥49 ¥31.9/半年
性价比之选
半年 500 条存储记录
查看完整历史记录
推荐
包年会员
¥99 ¥60.2/年
相当于每月 ¥5.0,性价比最高
每年 5000 条存储记录
查看完整历史记录
支付成功后额度将自动到账
"##; 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, /// 复用已有待支付订单的 order_no(不创建新订单) #[serde(default)] pub resume: Option, } #[get("/payment/page")] pub async fn payment_page( req: HttpRequest, pool: web::Data, query: web::Query, ) -> Result { check_payment_maintenance()?; 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()))?; // 复用已有待支付订单(如果提供了 resume order_no) let order_no = if let Some(ref resume_no) = query.resume { let existing: Option<(i32, String)> = sqlx::query_as( r#"SELECT user_id, status FROM payment_orders WHERE order_no = $1"#, ) .bind(resume_no) .fetch_optional(pool.get_ref()) .await .map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?; match existing { Some((uid, status)) if uid == claims.user_id && status == "pending" => { resume_no.clone() } _ => { // 订单不存在/不属于该用户/已支付 → 创建新订单 let new_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, &new_no, &query.package_, pkg.amount, expires_at, ).await?; new_no } } } else { // 查重:用户是否已有同套餐的待支付订单,有则复用 let existing: Option = sqlx::query_scalar( r#"SELECT order_no FROM payment_orders WHERE user_id = $1 AND package_type = $2 AND status = 'pending' ORDER BY created_at DESC LIMIT 1"#, ) .bind(claims.user_id) .bind(&query.package_) .fetch_optional(pool.get_ref()) .await .map_err(|e| AppError::Database(format!("查询已有订单失败: {}", e)))?; if let Some(no) = existing { no } else { let new_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, &new_no, &query.package_, pkg.amount, expires_at, ).await?; new_no } }; 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 { let jwt_for_mock = token.clone(); let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock); return Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(mock_html)); }; let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0); match build_alipay_form_html( &config, &order_no, &total_amount_str, pkg.display_name, ¬ify_url, &return_url, ) { 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 { check_payment_maintenance()?; 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 { let jwt_for_mock = token.clone(); let mock_html = build_mock_pay_html(&query.order_no, pkg.display_name, &jwt_for_mock); return Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(mock_html)); }; let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0); match build_alipay_form_html( &config, &query.order_no, &total_amount_str, pkg.display_name, ¬ify_url, &return_url, ) { 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 — 支付宝异步回调 ===== #[post("/payment/notify")] pub async fn alipay_notify( pool: web::Data, body: web::Form>, ) -> HttpResponse { let body = body.into_inner(); let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default(); let trade_status = body.get("trade_status").cloned().unwrap_or_default(); tracing::info!( "收到支付宝回调: out_trade_no={}, trade_status={}", out_trade_no, trade_status ); // 1. 检查交易状态 if trade_status != "TRADE_SUCCESS" && trade_status != "TRADE_FINISHED" { return HttpResponse::Ok().body("success"); } // 2. 验证 RSA2 签名(防止伪造回调) let Some(config) = AlipayConfig::from_env() else { tracing::warn!("支付宝配置不存在,无法验证签名"); return HttpResponse::Ok().body("fail"); }; let sign = body.get("sign").cloned().unwrap_or_default(); // BTreeMap 已按 key 排序,直接拼接除 sign 和 sign_type 外的所有参数 let sign_source: String = body .iter() .filter(|(k, _)| *k != "sign" && *k != "sign_type") .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("&"); if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) { tracing::warn!("支付宝签名验证失败: {}", e); return HttpResponse::Ok().body("fail"); } // 3. 确认订单(通过 order_no,不校验 user_id) match db::confirm_payment_order_by_orderno(pool.get_ref(), &out_trade_no).await { Ok(_) => { tracing::info!("订单 {} 支付确认成功", out_trade_no); HttpResponse::Ok().body("success") } Err(e) => { tracing::error!("订单 {} 确认失败: {}", 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( _pool: web::Data, query: web::Query, ) -> HttpResponse { let order_no = query.order_no.as_deref().unwrap_or(""); // 不再在此处确认订单(安全原因: 此端点无认证, 任何人知道 order_no 即可激活会员)。 // Mock 支付由 mock_confirm 在跳转前确认, 真实支付宝由 notify 异步回调确认。 let html = build_success_html(order_no); HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html) } // ===== Handler: POST /payment/refund-notify — 支付宝退款异步回调 ===== #[post("/payment/refund-notify")] pub async fn alipay_refund_notify( pool: web::Data, body: web::Form>, ) -> HttpResponse { let body = body.into_inner(); let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default(); let refund_status = body.get("refund_status").cloned().unwrap_or_default(); tracing::info!( "收到支付宝退款回调: out_trade_no={}, refund_status={}", out_trade_no, refund_status ); // 只处理成功的退款 if refund_status != "REFUND_SUCCESS" { return HttpResponse::Ok().body("success"); } // 验证 RSA2 签名 let Some(config) = AlipayConfig::from_env() else { tracing::warn!("支付宝配置不存在,无法验证退款签名"); return HttpResponse::Ok().body("fail"); }; let sign = body.get("sign").cloned().unwrap_or_default(); let sign_source: String = body .iter() .filter(|(k, _)| *k != "sign" && *k != "sign_type") .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("&"); if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) { tracing::warn!("支付宝退款签名验证失败: {}", e); return HttpResponse::Ok().body("fail"); } // 处理退款:标记订单 + 撤销会员 match db::refund_payment_order(pool.get_ref(), &out_trade_no).await { Ok(_) => { tracing::info!("订单 {} 退款处理成功", out_trade_no); HttpResponse::Ok().body("success") } Err(e) => { tracing::error!("订单 {} 退款处理失败: {}", out_trade_no, e); HttpResponse::Ok().body("fail") } } } // ===== 旧的 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 { check_payment_maintenance()?; 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, } }))) } /// 检查 Mock 支付是否允许 /// 规则(依次): /// 1. 已配置支付宝 → 禁用(真实支付优先) /// 2. MOCK_PAY_ENABLED != true → 禁用 /// 3. MOCK_PAY_KEY 已设置 → 验证 X-Mock-Key 请求头 fn check_mock_payment_allowed(req: &HttpRequest) -> Result<(), AppError> { // 规则 1:有支付宝时永不走 Mock if AlipayConfig::from_env().is_some() { return Err(AppError::BadRequest("真实支付已启用,Mock 支付不可用".to_string())); } // 规则 2:必须显式启用 Mock 支付 if std::env::var("MOCK_PAY_ENABLED").ok() != Some("true".to_string()) { return Err(AppError::Forbidden("Mock 支付未启用".to_string())); } // 规则 3:如果设了 MOCK_PAY_KEY,验证请求头 if let Ok(key) = std::env::var("MOCK_PAY_KEY") { if !key.is_empty() { let header_key = req .headers() .get("X-Mock-Key") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if header_key != key { return Err(AppError::Forbidden("Mock 支付密钥错误".to_string())); } } } Ok(()) } /// POST /api/payment/mock-confirm /// 所有 Mock 操作均需密钥授权(通过 MOCK_PAY_ENABLED + MOCK_PAY_KEY 控制) #[post("/api/payment/mock-confirm")] pub async fn mock_confirm( req: HttpRequest, pool: web::Data, claims: web::ReqData, body: web::Json, ) -> Result { check_payment_maintenance()?; check_mock_payment_allowed(&req)?; 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_active_member": true, "membership_expires_at": expires_at, } }))) } /// POST /api/payment/sync-order — 根据订单号强制同步会员状态 /// /// 查询订单和会员的当前最新状态(不执行确认操作,仅查询) #[post("/api/payment/sync-order")] pub async fn sync_order( pool: web::Data, claims: web::ReqData, body: web::Json, ) -> Result { check_payment_maintenance()?; let user_id = claims.user_id; // 查询订单当前状态(不执行确认,防止误确认未支付订单) let order_status: Option = sqlx::query_scalar( r#"SELECT status FROM payment_orders WHERE order_no = $1 AND user_id = $2"#, ) .bind(&body.order_id) .bind(user_id) .fetch_optional(pool.get_ref()) .await .map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))? .unwrap_or_default(); // 查询会员最新状态 let user = sqlx::query_as::<_, (bool, Option>)>( r#"SELECT is_member, membership_expires_at FROM users WHERE id = $1"#, ) .bind(user_id) .fetch_optional(pool.get_ref()) .await .map_err(|e| AppError::Database(format!("查询用户失败: {}", e)))? .ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?; let (is_member, membership_expires_at) = user; let is_active_member = is_member && membership_expires_at.map_or(true, |expires| expires > Utc::now()); Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "data": { "order_status": order_status, "is_member": is_member, "is_active_member": is_active_member, "membership_expires_at": membership_expires_at, } }))) } /// POST /api/payment/cancel-order — 用户主动取消待支付订单 #[derive(Debug, Deserialize)] pub struct CancelOrderRequest { pub order_id: String, } #[post("/api/payment/cancel-order")] pub async fn cancel_order( pool: web::Data, claims: web::ReqData, body: web::Json, ) -> Result { check_payment_maintenance()?; db::cancel_payment_order(pool.get_ref(), &body.order_id, claims.user_id).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "message": "订单已取消" }))) } /// GET /api/payment/orders — 获取当前用户的订单记录 #[get("/api/payment/orders")] pub async fn get_user_orders( pool: web::Data, claims: web::ReqData, ) -> Result { let orders = db::get_user_orders(pool.get_ref(), claims.user_id).await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "data": orders.iter().map(|o| serde_json::json!({ "order_no": o.order_no, "package_type": o.package_type, "amount": o.amount, "status": o.status, "paid_at": o.paid_at, "expires_at": o.expires_at, "created_at": o.created_at, })).collect::>() }))) } /// 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 is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()); let (used, is_active_member, membership_expires_at, was_member) = db::get_user_quota(pool.get_ref(), user_id).await?; // 维护模式使用更高的临时限额,但非会员仍然有限制防止滥用 let (limit, unlimited) = if is_maintenance && !is_active_member { let ml: i64 = std::env::var("MAINTENANCE_MODE_DATA_LIMIT") .ok().and_then(|v| v.parse().ok()).unwrap_or(500); (ml, false) } else if is_active_member { (0, true) } else { let fl: i64 = std::env::var("FREE_USER_DATA_LIMIT") .ok().and_then(|v| v.parse().ok()).unwrap_or(20); (fl, false) }; let active = if is_maintenance { true } else { is_active_member }; Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "data": { "used": used, "limit": limit, "unlimited": unlimited, "is_active_member": active, "membership_expires_at": membership_expires_at, "was_member": was_member, "maintenance_mode": is_maintenance, } }))) } /// 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_active_member: bool, pub membership_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_active_member: false, membership_expires_at: None, })); } }; // 检查是否过期 if Utc::now() > expires_at { if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") .bind(&db_code) .execute(pool.get_ref()) .await { tracing::warn!("清理过期登录码失败: {}", e); } return Ok(HttpResponse::Ok().json(LoginStatusResponse { success: false, confirmed: false, token: None, is_active_member: false, membership_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_active_member: false, membership_expires_at: None, })); } // 已确认 → 使用已生成的 token let token = token.unwrap(); let user_id = user_id.unwrap(); let (is_active_member, membership_expires_at): (bool, Option) = match sqlx::query_as::<_, (bool, Option>)>( "SELECT is_member, membership_expires_at FROM users WHERE id = $1" ) .bind(user_id) .fetch_optional(pool.get_ref()) .await { Ok(Some((is_member, expires))) => { let active = is_member && expires.map_or(true, |e| e > Utc::now()); (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_active_member, membership_expires_at, })) }