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)
|
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(
|
pub async fn create_payment_order(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: i32,
|
user_id: i32,
|
||||||
@@ -346,6 +384,9 @@ pub async fn create_payment_order(
|
|||||||
amount: i32,
|
amount: i32,
|
||||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
// 创建前先清理该用户的过期 pending 订单
|
||||||
|
let _ = cleanup_expired_pending_orders(pool, Some(user_id)).await;
|
||||||
|
|
||||||
let query = r#"
|
let query = r#"
|
||||||
INSERT INTO payment_orders (user_id, order_no, package_type, amount, expires_at)
|
INSERT INTO payment_orders (user_id, order_no, package_type, amount, expires_at)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
@@ -364,12 +405,26 @@ pub async fn create_payment_order(
|
|||||||
Ok(())
|
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)
|
/// 返回该订单的 expires_at(永久会员为 None)
|
||||||
/// 确认订单支付(模拟):更新订单状态 + 激活用户付费(累加有效期)
|
/// 确认订单支付(模拟):更新订单状态 + 激活用户付费(累加有效期)
|
||||||
///
|
///
|
||||||
/// 返回计算后的到期时间(永久会员返回 2099-12-31)
|
/// 返回计算后的到期时间(永久会员返回 None)
|
||||||
pub async fn confirm_payment_order(
|
pub async fn confirm_payment_order(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
order_no: &str,
|
order_no: &str,
|
||||||
@@ -407,7 +462,7 @@ pub async fn confirm_payment_order(
|
|||||||
is_paid = true,
|
is_paid = true,
|
||||||
paid_expires_at =
|
paid_expires_at =
|
||||||
CASE
|
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()) +
|
ELSE GREATEST(COALESCE(users.paid_expires_at, NOW()), NOW()) +
|
||||||
CASE
|
CASE
|
||||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||||
@@ -462,7 +517,7 @@ pub async fn confirm_payment_order_by_orderno(
|
|||||||
is_paid = true,
|
is_paid = true,
|
||||||
paid_expires_at =
|
paid_expires_at =
|
||||||
CASE
|
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()) +
|
ELSE GREATEST(COALESCE(users.paid_expires_at, NOW()), NOW()) +
|
||||||
CASE
|
CASE
|
||||||
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
WHEN uo.package_type = 'monthly' THEN INTERVAL '30 days'
|
||||||
@@ -482,6 +537,75 @@ pub async fn confirm_payment_order_by_orderno(
|
|||||||
Ok(())
|
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>) =
|
let paid_info = sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||||
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
"SELECT is_paid, paid_expires_at FROM users WHERE id = $1",
|
||||||
"SELECT is_paid_active($1)",
|
)
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.fetch_optional(pool.get_ref())
|
||||||
.fetch_optional(pool.get_ref())
|
.await
|
||||||
.await
|
.ok()
|
||||||
{
|
.flatten();
|
||||||
Ok(Some((active, expires))) => (active, expires.map(|e| e.to_rfc3339())),
|
|
||||||
_ => (false, None),
|
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")
|
let base_url = std::env::var("APP_BASE_URL")
|
||||||
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
|
.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_notify;
|
||||||
pub use payment::alipay_pay_page;
|
pub use payment::alipay_pay_page;
|
||||||
|
pub use payment::alipay_refund_notify;
|
||||||
pub use payment::create_order;
|
pub use payment::create_order;
|
||||||
pub use payment::generate_code;
|
pub use payment::generate_code;
|
||||||
pub use payment::get_user_quota;
|
pub use payment::get_user_quota;
|
||||||
|
|||||||
@@ -844,6 +844,61 @@ pub async fn payment_success(
|
|||||||
.body(html)
|
.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(保持兼容)=====
|
// ===== 旧的 API Handler(保持兼容)=====
|
||||||
|
|
||||||
/// POST /api/payment/create-order
|
/// 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::middleware::{from_fn, DefaultHeaders};
|
||||||
use actix_web::{App, HttpServer, web};
|
use actix_web::{App, HttpServer, web};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod};
|
use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
@@ -18,7 +18,7 @@ use auth::jwt_middleware;
|
|||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use db::create_pool;
|
use db::create_pool;
|
||||||
use handlers::{
|
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,
|
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_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,
|
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(payment_success)
|
||||||
.service(alipay_pay_page) // GET /payment/pay(需 JWT)
|
.service(alipay_pay_page) // GET /payment/pay(需 JWT)
|
||||||
.service(alipay_notify) // POST /payment/notify(支付宝异步回调)
|
.service(alipay_notify) // POST /payment/notify(支付宝异步回调)
|
||||||
|
.service(alipay_refund_notify) // POST /payment/refund-notify(支付宝退款回调)
|
||||||
.service(payment_login_status) // GET /payment/login-status(网页轮询)
|
.service(payment_login_status) // GET /payment/login-status(网页轮询)
|
||||||
// 静态文件(无需认证)
|
// 静态文件(无需认证)
|
||||||
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
|
.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();
|
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...");
|
info!("Attempting to start server...");
|
||||||
|
|
||||||
let is_production = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) == "production";
|
let is_production = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) == "production";
|
||||||
|
|||||||
Reference in New Issue
Block a user