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

180
src/error.rs Normal file
View File

@@ -0,0 +1,180 @@
use actix_web::{ResponseError, http::StatusCode, HttpResponse};
use serde::Serialize;
use std::fmt;
#[derive(Debug, Serialize)]
pub struct ErrorResponse<T = ()> {
pub success: bool,
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl<T> ErrorResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
}
}
pub fn error(msg: impl Into<String>) -> Self {
Self {
success: false,
data: None,
error: Some(msg.into()),
}
}
}
impl<T: Serialize> ErrorResponse<T> {
pub fn to_json_response(self) -> HttpResponse {
HttpResponse::Ok().json(self)
}
}
impl ErrorResponse<()> {
pub fn to_error_response(&self, status: StatusCode) -> HttpResponse {
let body = serde_json::json!({
"success": false,
"error": self.error.clone().unwrap_or_else(|| "Unknown error".to_string())
});
HttpResponse::build(status).json(body)
}
}
#[derive(Debug)]
pub enum AppError {
Unauthorized(String),
Forbidden(String),
NotFound(String),
BadRequest(String),
Internal(String),
Database(String),
TooManyRequests(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Unauthorized(msg) => write!(f, "{}", msg),
AppError::Forbidden(msg) => write!(f, "{}", msg),
AppError::NotFound(msg) => write!(f, "{}", msg),
AppError::BadRequest(msg) => write!(f, "{}", msg),
AppError::Internal(msg) => write!(f, "{}", msg),
AppError::Database(msg) => write!(f, "{}", msg),
AppError::TooManyRequests(msg) => write!(f, "{}", msg),
}
}
}
impl ResponseError for AppError {
fn status_code(&self) -> StatusCode {
match self {
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Internal(_) | AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
}
}
fn error_response(&self) -> actix_web::HttpResponse {
let error_message = self.to_string();
let status_code = self.status_code().as_u16();
let html = format!(r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>错误 {status_code}</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, #e74c3c 0%, #c0392b 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;
}}
.error-code {{
font-size: 72px;
font-weight: bold;
color: #e74c3c;
margin-bottom: 16px;
}}
h1 {{
color: #333;
margin-bottom: 16px;
font-size: 24px;
}}
.message {{
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
margin: 24px 0;
color: #555;
font-size: 14px;
line-height: 1.6;
}}
.hint {{
color: #888;
font-size: 13px;
margin-top: 24px;
}}
.back-link {{
display: inline-block;
margin-top: 24px;
padding: 12px 24px;
background: #3498db;
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 14px;
transition: background 0.3s;
}}
.back-link:hover {{
background: #2980b9;
}}
</style>
</head>
<body>
<div class="container">
<div class="error-code">{status_code}</div>
<h1>{error_title}</h1>
<div class="message">{error_message}</div>
<p style="margin-top: 24px; color: #3498db; font-size: 14px;">请返回小程序重新生成新的下载链接</p>
<div class="hint">如果问题持续存在,请联系技术支持</div>
</div>
</body>
</html>"#,
error_title = match self {
AppError::Unauthorized(_) => "认证失败",
AppError::Forbidden(_) => "权限不足",
AppError::NotFound(_) => "内容未找到",
AppError::BadRequest(_) => "请求无效",
AppError::Internal(_) | AppError::Database(_) => "服务器错误",
AppError::TooManyRequests(_) => "请求过于频繁",
}
);
HttpResponse::build(self.status_code())
.content_type("text/html; charset=utf-8")
.body(html)
}
}