377 lines
15 KiB
Rust
377 lines
15 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,
|
||
admin_create_notification, admin_delete_notification, 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, list_notifications, login, mark_all_read, mark_notification_read, unread_count, 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_guest_login, 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(网页轮询)
|
||
// robots.txt(无需认证)
|
||
.route("/robots.txt", web::get().to(|| async { serve_static_files(web::Path::from("robots.txt".to_string())).await }))
|
||
// 静态文件(无需认证)
|
||
.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(web_guest_login) // POST /api/guest-login — 访客登录(免微信)
|
||
.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(admin_create_notification)
|
||
.service(admin_delete_notification)
|
||
.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(list_notifications)
|
||
.service(mark_notification_read)
|
||
.service(mark_all_read)
|
||
.service(unread_count)
|
||
.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() {
|
||
info!("Sentry 已初始化");
|
||
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 {
|
||
info!("SENTRY_DSN 为空,跳过 Sentry 初始化");
|
||
None
|
||
}
|
||
} else {
|
||
info!("未设置 SENTRY_DSN,跳过 Sentry 初始化");
|
||
None
|
||
};
|
||
|
||
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_ansi(true)
|
||
.compact()
|
||
.init();
|
||
info!("日志系统初始化成功,输出到 systemd journal (journalctl)");
|
||
info!("日志级别: {}", app_config.rust_log);
|
||
|
||
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::builder()
|
||
.timeout(std::time::Duration::from_secs(10))
|
||
.connect_timeout(std::time::Duration::from_secs(5))
|
||
.build()
|
||
.unwrap_or_else(|e| {
|
||
panic!("创建 HTTP 客户端失败: {}", e);
|
||
});
|
||
|
||
// 启动时清理:过期订单 + 过期登录码
|
||
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),
|
||
}
|
||
|
||
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!("[定时任务] 开始...");
|
||
|
||
// 会员到期前 7 天提醒
|
||
if let Err(e) = db::check_member_expiry_soon(&pool_clone).await {
|
||
tracing::error!("检查会员到期失败: {}", e);
|
||
}
|
||
|
||
// 清理超过 24 小时的过期待支付订单
|
||
if let Err(e) = db::cleanup_expired_pending_orders(&pool_clone, None).await {
|
||
tracing::error!("清理过期订单失败: {}", e);
|
||
}
|
||
|
||
// 清理过期的 refresh_token
|
||
if let Err(e) = db::cleanup_expired_refresh_tokens(&pool_clone).await {
|
||
tracing::error!("清理过期 refresh token 失败: {}", e);
|
||
}
|
||
}
|
||
});
|
||
|
||
info!("Attempting to start server...");
|
||
|
||
let is_production = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into()) == "production";
|
||
|
||
// 端口优先级:SERVER_PORT 环境变量 > 环境默认
|
||
let ports: Vec<u16> = if let Ok(port_str) = std::env::var("SERVER_PORT") {
|
||
if let Ok(port) = port_str.parse::<u16>() {
|
||
vec![port]
|
||
} else {
|
||
vec![]
|
||
}
|
||
} else 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 || *port == 4434 {
|
||
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())
|
||
})
|
||
.keep_alive(std::time::Duration::from_secs(30))
|
||
.shutdown_timeout(10)
|
||
.backlog(1024)
|
||
.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())
|
||
})
|
||
.keep_alive(std::time::Duration::from_secs(30))
|
||
.shutdown_timeout(10)
|
||
.backlog(1024)
|
||
.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);
|
||
}
|
||
}
|
||
}
|