fix: 修复付费机制失效处理的5个后端缺口
- 新增POST /payment/refund-notify支付宝退款Webhook - 新增待支付订单自动清理(超过24h→cancelled, 含启动清理) - 新增web登录码过期启动清理 - 永久会员用NULL替代硬编码2099-12-31 - 修复缺失的is_paid_active数据库函数(改用内联SQL)
This commit is contained in:
130
src/db.rs
130
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<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(())
|
||||
}
|
||||
|
||||
/// 获取用户配额信息
|
||||
///
|
||||
/// 返回 (已用条数, 是否付费活跃, 到期时间)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
16
src/main.rs
16
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";
|
||||
|
||||
Reference in New Issue
Block a user