357 lines
14 KiB
Rust
357 lines
14 KiB
Rust
use actix_web::middleware::{from_fn, DefaultHeaders};
|
||
use actix_web::{App, HttpServer, web};
|
||
use tracing::{error, info, warn};
|
||
use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod};
|
||
use reqwest::Client;
|
||
use sqlx::postgres::PgPool;
|
||
use std::pin::Pin;
|
||
|
||
mod auth;
|
||
mod config;
|
||
mod db;
|
||
mod error;
|
||
mod handlers;
|
||
mod models;
|
||
mod rate_limiter;
|
||
|
||
use auth::jwt_middleware;
|
||
use config::AppConfig;
|
||
use db::create_pool;
|
||
use handlers::{
|
||
admin_force_confirm_order, admin_get_user, admin_refund_order, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page, alipay_refund_notify, cancel_order,
|
||
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,
|
||
post_weather_data, report_frontend_error,
|
||
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
||
web_generate_login_code, web_login_confirm, web_login_auto_confirm,
|
||
};
|
||
use models::AppState;
|
||
|
||
// 加载TLS证书和私钥
|
||
fn create_ssl_acceptor() -> Result<SslAcceptorBuilder, Box<dyn std::error::Error>> {
|
||
let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?;
|
||
|
||
// 从环境变量获取证书和私钥路径
|
||
let key_path =
|
||
std::env::var("SSL_KEY_PATH").unwrap_or_else(|_| "path/to/private.key".to_string());
|
||
let cert_path =
|
||
std::env::var("SSL_CERT_PATH").unwrap_or_else(|_| "path/to/certificate.crt".to_string());
|
||
|
||
builder.set_private_key_file(&key_path, SslFiletype::PEM)?;
|
||
builder.set_certificate_chain_file(&cert_path)?;
|
||
|
||
// 返回构建器
|
||
Ok(builder)
|
||
}
|
||
|
||
fn create_server_config(
|
||
pool: PgPool,
|
||
http_client: Client,
|
||
app_state: AppState,
|
||
) -> App<
|
||
impl actix_web::dev::ServiceFactory<
|
||
actix_web::dev::ServiceRequest,
|
||
Config = (),
|
||
Response = actix_web::dev::ServiceResponse,
|
||
Error = actix_web::Error,
|
||
InitError = (),
|
||
>,
|
||
> {
|
||
App::new()
|
||
.app_data(web::Data::new(pool))
|
||
.app_data(web::Data::new(http_client))
|
||
.app_data(web::Data::new(app_state))
|
||
// 安全响应头(全局中间件)
|
||
.wrap(
|
||
DefaultHeaders::new()
|
||
.add(("X-Content-Type-Options", "nosniff"))
|
||
.add(("X-Frame-Options", "DENY"))
|
||
.add(("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'"))
|
||
.add(("Referrer-Policy", "no-referrer-when-downgrade"))
|
||
.add(("Permissions-Policy", "geolocation=(), microphone=(), camera=()"))
|
||
)
|
||
// Sentry 请求监控(在 DSN 未配置时无操作)
|
||
.wrap(sentry_actix::Sentry::new())
|
||
// 根路径(无需认证)
|
||
.service(root) // #[get("/")] - 返回服务信息
|
||
// 支付页面(无需认证,外部浏览器访问)
|
||
.service(payment_index) // GET /payment — 套餐选择页
|
||
.service(generate_code) // GET /payment/generate-code — 网页生成登录码
|
||
.service(payment_page) // GET /payment/page(需 JWT)
|
||
.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)))
|
||
// API 接口
|
||
.service(login) // #[post("/api/login")]
|
||
.service(mock_login) // #[get("/api/mock-login")](沙箱测试用)
|
||
.service(refresh_token) // #[post("/api/refresh-token")](公开接口,无需认证)
|
||
.service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token,公开接口)
|
||
.service(health_check)
|
||
.service(web_generate_login_code)
|
||
.service(web_login_confirm)
|
||
.service(web_login_auto_confirm)
|
||
.service(report_frontend_error) // POST /api/sentry/events
|
||
.service(
|
||
web::scope("")
|
||
.wrap(from_fn(jwt_middleware))
|
||
.service(post_weather_data)
|
||
.service(get_weather_brief)
|
||
.service(generate_temp_token_handler)
|
||
.service(delete_weather)
|
||
.service(get_current_user_profile)
|
||
.service(save_user_profile)
|
||
.service(admin_get_user)
|
||
.service(admin_update_user_payment)
|
||
.service(admin_force_confirm_order)
|
||
.service(admin_refund_order)
|
||
.service(create_order)
|
||
.service(mock_confirm)
|
||
.service(sync_order)
|
||
.service(cancel_order)
|
||
.service(get_user_orders)
|
||
.service(get_user_quota)
|
||
.service(get_favorites)
|
||
.service(add_favorite)
|
||
.service(remove_favorite)
|
||
)
|
||
}
|
||
|
||
#[actix_web::main]
|
||
async fn main() -> std::io::Result<()> {
|
||
// 加载配置文件(支持多环境)
|
||
let app_config = match AppConfig::load() {
|
||
Ok(cfg) => cfg,
|
||
Err(e) => {
|
||
eprintln!("配置加载失败: {}", e);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
// 设置环境变量(向后兼容依赖 env var 的组件)
|
||
unsafe {
|
||
std::env::set_var("DATABASE_URL", &app_config.database_url);
|
||
std::env::set_var("JWT_SECRET", &app_config.jwt_secret);
|
||
std::env::set_var("WECHAT_APPID", &app_config.wechat_appid);
|
||
std::env::set_var("WECHAT_SECRET", &app_config.wechat_secret);
|
||
std::env::set_var("SSL_KEY_PATH", &app_config.ssl_key_path);
|
||
std::env::set_var("SSL_CERT_PATH", &app_config.ssl_cert_path);
|
||
std::env::set_var("RUST_LOG", &app_config.rust_log);
|
||
std::env::set_var("FREE_USER_DATA_LIMIT", app_config.free_user_data_limit.to_string());
|
||
}
|
||
|
||
// Sentry 初始化(仅在 SENTRY_DSN 环境变量已设置时启用)
|
||
let _sentry_guard = if let Ok(dsn) = std::env::var("SENTRY_DSN") {
|
||
if !dsn.is_empty() {
|
||
eprintln!("[SENTRY] Sentry 已初始化 (DSN: {})", dsn);
|
||
Some(sentry::init((
|
||
dsn,
|
||
sentry::ClientOptions {
|
||
release: sentry::release_name!(),
|
||
traces_sample_rate: std::env::var("SENTRY_TRACES_SAMPLE_RATE")
|
||
.ok()
|
||
.and_then(|v| v.parse::<f32>().ok())
|
||
.unwrap_or(0.2),
|
||
..Default::default()
|
||
},
|
||
)))
|
||
} else {
|
||
eprintln!("[SENTRY] SENTRY_DSN 为空,跳过 Sentry 初始化");
|
||
None
|
||
}
|
||
} else {
|
||
eprintln!("[SENTRY] 未设置 SENTRY_DSN,跳过 Sentry 初始化");
|
||
None
|
||
};
|
||
|
||
// 初始化文件日志(JSON 格式,带轮转)
|
||
let log_dir = std::path::Path::new("./logs");
|
||
std::fs::create_dir_all(log_dir).ok(); // 确保日志目录存在
|
||
|
||
let file_appender = tracing_appender::rolling::Builder::new()
|
||
.rotation(tracing_appender::rolling::Rotation::DAILY)
|
||
.filename_prefix("rust-backend")
|
||
.filename_suffix("log")
|
||
.build(log_dir)
|
||
.expect("无法创建日志文件");
|
||
|
||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||
|
||
// 保持文件 guard 存活(使用 Box 泄漏)
|
||
std::mem::forget(_guard);
|
||
|
||
use std::str::FromStr;
|
||
let default_directive = tracing_subscriber::filter::Directive::from_str("info").unwrap();
|
||
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::from_default_env()
|
||
.add_directive(
|
||
format!("rust_backend={}", app_config.rust_log)
|
||
.parse()
|
||
.unwrap_or(default_directive.clone())
|
||
)
|
||
.add_directive(
|
||
format!("actix_web={}", app_config.rust_log)
|
||
.parse()
|
||
.unwrap_or(default_directive)
|
||
)
|
||
.add_directive(
|
||
"sqlx=warn".parse()
|
||
.expect("sqlx=warn 是合法的日志指令")
|
||
)
|
||
)
|
||
.with_target(true)
|
||
.with_thread_ids(false) // 生产环境可开启
|
||
.with_file(true)
|
||
.with_line_number(true)
|
||
.with_writer(non_blocking)
|
||
.with_ansi(false) // 文件中不使用 ANSI 颜色
|
||
.json() // JSON 格式便于 ELK 解析
|
||
.init();
|
||
info!("日志系统初始化成功,JSON格式,输出到 ./logs/");
|
||
|
||
let app_state = match AppState::load() {
|
||
Ok(state) => state,
|
||
Err(e) => {
|
||
error!("环境变量配置错误: {}", e);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
let pool = match create_pool().await {
|
||
Ok(pool) => pool,
|
||
Err(e) => {
|
||
error!("Failed to create database pool: {}", e);
|
||
error!("Please check your database connection configuration in .env file");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
let http_client = Client::new();
|
||
|
||
// 启动时清理:过期订单 + 过期登录码
|
||
match db::cleanup_expired_pending_orders(&pool, None).await {
|
||
Ok(n) => info!("已清理 {} 个过期待支付订单(启动时)", n),
|
||
Err(e) => warn!("启动时清理过期待支付订单失败: {}", e),
|
||
}
|
||
match db::cleanup_expired_login_codes(&pool).await {
|
||
Ok(n) => info!("已清理 {} 个过期的 web 登录码(启动时)", n),
|
||
Err(e) => warn!("启动时清理 web 登录码失败: {}", e),
|
||
}
|
||
|
||
// 后台定时扫描待支付订单(每 5 分钟)
|
||
let pool_clone = pool.clone();
|
||
actix_web::rt::spawn(async move {
|
||
let mut interval = actix_web::rt::time::interval(std::time::Duration::from_secs(300));
|
||
interval.tick().await; // 跳过立即执行
|
||
loop {
|
||
interval.tick().await;
|
||
tracing::info!("[定时任务] 开始扫描待支付订单...");
|
||
match db::check_and_retry_pending_orders(&pool_clone).await {
|
||
Ok(confirmed) => {
|
||
if confirmed > 0 {
|
||
tracing::info!("[定时任务] 自动确认了 {} 个待支付订单", confirmed);
|
||
}
|
||
}
|
||
Err(e) => tracing::warn!("[定时任务] 扫描待支付订单失败: {}", e),
|
||
}
|
||
}
|
||
});
|
||
|
||
info!("Attempting to start server...");
|
||
|
||
let is_production = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) == "production";
|
||
let ports: Vec<u16> = if is_production {
|
||
vec![4433]
|
||
} else {
|
||
vec![8080, 3000]
|
||
};
|
||
let mut server: Option<
|
||
Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Unpin>>,
|
||
> = None;
|
||
let mut bound_port = 0;
|
||
|
||
for port in &ports {
|
||
let addr = format!("127.0.0.1:{}", port);
|
||
info!("Trying to bind to {}", addr);
|
||
|
||
let pool_clone = pool.clone();
|
||
let http_client_clone = http_client.clone();
|
||
let app_state_clone = app_state.clone();
|
||
|
||
if *port == 443 || *port == 8443 || *port == 4433 {
|
||
let ssl_builder = match create_ssl_acceptor() {
|
||
Ok(builder) => builder,
|
||
Err(e) => {
|
||
error!("Failed to create SSL acceptor: {}", e);
|
||
continue;
|
||
}
|
||
};
|
||
|
||
match HttpServer::new(move || {
|
||
create_server_config(pool_clone.clone(), http_client_clone.clone(), app_state_clone.clone())
|
||
})
|
||
.bind_openssl(&addr, ssl_builder)
|
||
{
|
||
Ok(s) => {
|
||
info!("Successfully bound to {} with HTTPS", addr);
|
||
let server_future = s.run();
|
||
server = Some(Box::pin(server_future));
|
||
bound_port = *port;
|
||
break;
|
||
}
|
||
Err(e) => {
|
||
error!("Failed to bind to {} with HTTPS: {}", addr, e);
|
||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||
error!(" -> Permission denied. Try running with sudo or use a port > 1024");
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
} else {
|
||
match HttpServer::new(move || {
|
||
create_server_config(pool_clone.clone(), http_client_clone.clone(), app_state_clone.clone())
|
||
})
|
||
.bind(&addr)
|
||
{
|
||
Ok(s) => {
|
||
info!("Successfully bound to {} with HTTP", addr);
|
||
let server_future = s.run();
|
||
server = Some(Box::pin(server_future));
|
||
bound_port = *port;
|
||
break;
|
||
}
|
||
Err(e) => {
|
||
error!("Failed to bind to {} with HTTP: {}", addr, e);
|
||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||
error!(
|
||
" -> Permission denied. Try running with sudo or use a port > 1024"
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
match server {
|
||
Some(s) => {
|
||
if bound_port == 443 || bound_port == 8443 {
|
||
info!("Server started successfully with HTTPS");
|
||
} else {
|
||
info!("Server started successfully with HTTP");
|
||
}
|
||
s.await
|
||
}
|
||
None => {
|
||
error!("Failed to bind to any port. Please check your system configuration.");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
}
|