feat(auth): 实现登录限流和性能优化

性能优化:
- 添加数据库索引优化查询性能 (006)
  - weather_data: user_id, date, is_favorite 索引
  - users: openid 索引
  - payment_orders: status 索引
- 新增 refresh_tokens 表支持双 Token 机制 (004)
- 新增 web_login_codes 表支持网页端扫码登录 (005)

安全增强:
- 实现基于 IP 的登录限流 (rate_limiter.rs)
  - 滑动窗口算法: 5次/分钟/IP
  - 自动清理过期记录
  - 429 TooManyRequests 响应

新模块:
- src/rate_limiter.rs: 限流模块
- src/alipay.rs: 支付宝签名模块 (RSA2)
- src/error.rs: 统一错误类型 (含 TooManyRequests)
- src/handlers/meta.rs: 元数据处理器

代码清理:
- 修复 .gitignore 规则,正确跟踪 src/ 和 migrations/
This commit is contained in:
2026-04-24 16:13:49 +08:00
parent b3dc966576
commit e6048ea010
11 changed files with 852 additions and 9 deletions

View File

@@ -1,4 +1,4 @@
use actix_web::{web, HttpResponse, Responder, post, get};
use actix_web::{web, HttpResponse, HttpRequest, Responder, post, get};
use chrono::{Utc, Duration};
use serde::{Deserialize, Serialize};
use reqwest::Client;
@@ -13,6 +13,8 @@ use crate::models::{
AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse,
WeChatApiResponse, WeChatLoginRequest,
};
use crate::rate_limiter::LOGIN_RATE_LIMITER;
use std::sync::Arc;
#[post("/api/login")]
pub async fn login(
@@ -20,7 +22,27 @@ pub async fn login(
req: web::Json<WeChatLoginRequest>,
http_client: web::Data<Client>,
app_state: web::Data<AppState>,
http_req: HttpRequest,
) -> impl Responder {
let client_ip = http_req
.headers()
.get("X-Forwarded-For")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.or_else(|| {
http_req
.headers()
.get("X-Real-IP")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "unknown".to_string());
if let Err(e) = LOGIN_RATE_LIMITER.check_rate_limit(&client_ip).await {
warn!("登录请求被限流: client_ip={}", client_ip);
return HttpResponse::TooManyRequests().json(ErrorResponse::<()>::error(e.to_string()));
}
let url = format!(
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
app_state.wechat_appid, app_state.wechat_secret, req.code

112
src/handlers/meta.rs Normal file
View File

@@ -0,0 +1,112 @@
use actix_web::{get, web, HttpResponse, Responder};
use sqlx::postgres::PgPool;
const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大气稳定度判定系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
padding: 48px;
max-width: 480px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}
h1 {
color: #333;
margin-bottom: 8px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 32px;
font-size: 14px;
}
.status-box {
padding: 24px;
border-radius: 12px;
margin-bottom: 24px;
}
.status-ok {
background: #d4edda;
border: 1px solid #28a745;
}
.status-error {
background: #f8d7da;
border: 1px solid #dc3545;
}
.status-icon {
font-size: 48px;
margin-bottom: 16px;
}
.status-text {
font-size: 24px;
font-weight: bold;
margin-bottom: 8px;
}
.status-ok .status-text { color: #155724; }
.status-error .status-text { color: #721c24; }
.status-detail {
color: #495057;
font-size: 14px;
}
.version {
color: #999;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>大气稳定度判定系统</h1>
<p class="subtitle">后端服务状态</p>
<div class="status-box {status_class}">
<div class="status-icon">{status_icon}</div>
<div class="status-text">{status_text}</div>
<div class="status-detail">
<div>数据库: {database}</div>
</div>
</div>
<div class="version">v{version}</div>
</div>
</body>
</html>"#;
#[get("/")]
pub async fn root(pool: web::Data<PgPool>) -> impl Responder {
let (status_class, status_icon, status_text, database) = match sqlx::query("SELECT 1")
.fetch_one(pool.get_ref())
.await
{
Ok(_) => ("status-ok", "", "服务正常", "已连接"),
Err(_) => ("status-error", "", "服务异常", "未连接"),
};
let html = HTML_TEMPLATE
.replace("{status_class}", status_class)
.replace("{status_icon}", status_icon)
.replace("{status_text}", status_text)
.replace("{database}", database)
.replace("{version}", env!("CARGO_PKG_VERSION"));
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html)
}