diff --git a/src/db.rs b/src/db.rs index df630be..eeb22f7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -338,6 +338,44 @@ pub async fn get_user_orders( Ok(rows) } +/// 清理超过 24 小时仍未支付的待处理订单 +pub async fn cleanup_expired_pending_orders(pool: &PgPool, user_id: Option) -> Result { + let affected = if let Some(uid) = user_id { + sqlx::query( + r#" + UPDATE payment_orders + SET status = 'cancelled' + WHERE status = 'pending' + AND created_at < NOW() - INTERVAL '24 hours' + AND user_id = $1 + "#, + ) + .bind(uid) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("清理过期订单失败: {}", e)))? + .rows_affected() + } else { + sqlx::query( + r#" + UPDATE payment_orders + SET status = 'cancelled' + WHERE status = 'pending' + AND created_at < NOW() - INTERVAL '24 hours' + "#, + ) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("清理过期订单失败: {}", e)))? + .rows_affected() + }; + + if affected > 0 { + tracing::info!("已清理 {} 个过期待支付订单", affected); + } + Ok(affected) +} + pub async fn create_payment_order( pool: &PgPool, user_id: i32, @@ -346,6 +384,9 @@ pub async fn create_payment_order( amount: i32, expires_at: Option>, ) -> Result<(), AppError> { + // 创建前先清理该用户的过期 pending 订单 + let _ = cleanup_expired_pending_orders(pool, Some(user_id)).await; + let query = r#" INSERT INTO payment_orders (user_id, order_no, package_type, amount, expires_at) VALUES ($1, $2, $3, $4, $5) @@ -364,12 +405,26 @@ pub async fn create_payment_order( Ok(()) } +/// 清理所有已过期的 web 登录码 +pub async fn cleanup_expired_login_codes(pool: &PgPool) -> Result { + let affected = sqlx::query("DELETE FROM web_login_codes WHERE expires_at < NOW()") + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("清理过期登录码失败: {}", e)))? + .rows_affected(); + + if affected > 0 { + tracing::info!("已清理 {} 个过期的 web 登录码", affected); + } + Ok(affected) +} + /// 确认订单支付(模拟):更新订单状态 + 激活用户付费 /// /// 返回该订单的 expires_at(永久会员为 None) /// 确认订单支付(模拟):更新订单状态 + 激活用户付费(累加有效期) /// -/// 返回计算后的到期时间(永久会员返回 2099-12-31) +/// 返回计算后的到期时间(永久会员返回 None) pub async fn confirm_payment_order( pool: &PgPool, order_no: &str, @@ -407,7 +462,7 @@ pub async fn confirm_payment_order( is_paid = true, paid_expires_at = CASE - WHEN uo.package_type = 'permanent' THEN '2099-12-31 23:59:59+00'::timestamptz + WHEN uo.package_type = 'permanent' THEN NULL ELSE GREATEST(COALESCE(users.paid_expires_at, NOW()), NOW()) + CASE WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' @@ -462,7 +517,7 @@ pub async fn confirm_payment_order_by_orderno( is_paid = true, paid_expires_at = CASE - WHEN uo.package_type = 'permanent' THEN '2099-12-31 23:59:59+00'::timestamptz + WHEN uo.package_type = 'permanent' THEN NULL ELSE GREATEST(COALESCE(users.paid_expires_at, NOW()), NOW()) + CASE WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days' @@ -482,6 +537,75 @@ pub async fn confirm_payment_order_by_orderno( Ok(()) } +/// 退款处理:标记订单为 refunded + 撤销用户会员(如果无其他有效订单) +pub async fn refund_payment_order( + pool: &PgPool, + order_no: &str, +) -> Result<(), AppError> { + // 先查出订单关联的用户 + let order_info: Option<(i32, String, Option>)> = + sqlx::query_as( + r#"SELECT user_id, status, paid_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, _paid_at) = match order_info { + Some(info) => info, + None => return Err(AppError::NotFound("订单不存在".to_string())), + }; + + if status != "paid" { + tracing::warn!("订单 {} 状态为 {},跳过退款处理", order_no, status); + return Ok(()); + } + + // 标记订单为 refunded + sqlx::query("UPDATE payment_orders SET status = 'refunded' WHERE order_no = $1") + .bind(order_no) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("退款标记失败: {}", e)))?; + + // 检查该用户是否有其他有效的已支付订单 + let other_active: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) FROM payment_orders + WHERE user_id = $1 + AND status = 'paid' + AND order_no != $2 + AND (expires_at IS NULL OR expires_at > NOW()) + "#, + ) + .bind(user_id) + .bind(order_no) + .fetch_one(pool) + .await + .map_err(|e| AppError::Database(format!("查询其他有效订单失败: {}", e)))?; + + // 如果没有其他有效订单,撤销会员状态 + if other_active.0 == 0 { + sqlx::query( + "UPDATE users SET is_paid = false, paid_expires_at = NULL WHERE id = $1", + ) + .bind(user_id) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("撤销会员失败: {}", e)))?; + tracing::info!("用户 {} 的会员因订单 {} 退款已被撤销", user_id, order_no); + } else { + tracing::info!( + "用户 {} 有其他有效订单({}笔),跳过会员撤销", + user_id, + other_active.0 + ); + } + + Ok(()) +} + /// 获取用户配额信息 /// /// 返回 (已用条数, 是否付费活跃, 到期时间) diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index c4097f3..f742a0d 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -713,17 +713,22 @@ pub async fn web_login_auto_confirm( } }; - 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 paid_info = sqlx::query_as::<_, (bool, Option>)>( + "SELECT is_paid, paid_expires_at FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(pool.get_ref()) + .await + .ok() + .flatten(); + + let (is_paid_active, paid_expires_at): (bool, Option) = match paid_info { + Some((is_paid, expires)) => { + let active = is_paid && expires.map_or(true, |e| e > Utc::now()); + (active, expires.map(|e| e.to_rfc3339())) + } + None => (false, None), + }; let base_url = std::env::var("APP_BASE_URL") .unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string()); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 1422bd3..9243c0d 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -38,6 +38,7 @@ pub use weather::post_weather_data; pub use payment::alipay_notify; pub use payment::alipay_pay_page; +pub use payment::alipay_refund_notify; pub use payment::create_order; pub use payment::generate_code; pub use payment::get_user_quota; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 160c87d..ed48cad 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -844,6 +844,61 @@ pub async fn payment_success( .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 diff --git a/src/main.rs b/src/main.rs index 7a8ba46..6fb39b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use actix_web::middleware::{from_fn, DefaultHeaders}; use actix_web::{App, HttpServer, web}; -use tracing::{error, info}; +use tracing::{error, info, warn}; use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod}; use reqwest::Client; use sqlx::postgres::PgPool; @@ -18,7 +18,7 @@ use auth::jwt_middleware; use config::AppConfig; use db::create_pool; use handlers::{ - admin_get_user, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page, + admin_get_user, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page, alipay_refund_notify, create_order, delete_weather, generate_code, generate_temp_token_handler, get_current_user_profile, get_favorites, get_user_quota, get_weather_brief, get_weather_details, get_user_orders, health_check, login, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success, @@ -82,6 +82,7 @@ fn create_server_config( .service(payment_success) .service(alipay_pay_page) // GET /payment/pay(需 JWT) .service(alipay_notify) // POST /payment/notify(支付宝异步回调) + .service(alipay_refund_notify) // POST /payment/refund-notify(支付宝退款回调) .service(payment_login_status) // GET /payment/login-status(网页轮询) // 静态文件(无需认证) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) @@ -230,6 +231,17 @@ async fn main() -> std::io::Result<()> { let http_client = Client::new(); + // 启动时清理所有超过 24 小时的待支付订单 + match db::cleanup_expired_pending_orders(&pool, None).await { + Ok(n) => info!("已清理 {} 个过期待支付订单(启动时)", n), + Err(e) => warn!("启动时清理过期待支付订单失败: {}", e), + } + // 启动时清理所有过期的 web 登录码 + match db::cleanup_expired_login_codes(&pool).await { + Ok(n) => info!("已清理 {} 个过期的 web 登录码(启动时)", n), + Err(e) => warn!("启动时清理 web 登录码失败: {}", e), + } + info!("Attempting to start server..."); let is_production = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) == "production";