fix: 修复付费机制失效处理的5个后端缺口

- 新增POST /payment/refund-notify支付宝退款Webhook
- 新增待支付订单自动清理(超过24h→cancelled, 含启动清理)
- 新增web登录码过期启动清理
- 永久会员用NULL替代硬编码2099-12-31
- 修复缺失的is_paid_active数据库函数(改用内联SQL)
This commit is contained in:
2026-05-25 12:07:01 +08:00
parent f9f1ffeff6
commit af2837e15e
5 changed files with 213 additions and 16 deletions

130
src/db.rs
View File

@@ -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<i32>) -> Result<u64, AppError> {
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<chrono::DateTime<chrono::Utc>>,
) -> 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<u64, AppError> {
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<chrono::DateTime<chrono::Utc>>)> =
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(())
}
/// 获取用户配额信息
///
/// 返回 (已用条数, 是否付费活跃, 到期时间)

View File

@@ -713,17 +713,22 @@ pub async fn web_login_auto_confirm(
}
};
let (is_paid_active, paid_expires_at): (bool, Option<String>) =
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
"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<chrono::DateTime<chrono::Utc>>)>(
"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<String>) = 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());

View File

@@ -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;

View File

@@ -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<PgPool>,
body: web::Form<BTreeMap<String, String>>,
) -> 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::<Vec<_>>()
.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

View File

@@ -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";