feat: 完善生产级 Rust Web 模板

This commit is contained in:
2026-07-21 16:00:03 +08:00
parent fb30d5da1a
commit ce89569bf3
58 changed files with 2042 additions and 349 deletions
+69 -27
View File
@@ -1,7 +1,15 @@
use crate::AppState;
use crate::{
error::ErrorResponse,
infra::middleware::{Language, UserId},
repositories::user_repository::UserRepository,
utils::{
i18n::{message, ZH_CN},
jwt::TokenType,
},
AppState,
};
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
};
@@ -10,42 +18,76 @@ use serde::Deserialize;
#[derive(Deserialize)]
pub struct Claims {
pub sub: String, // user_id
pub sub: String,
#[allow(dead_code)]
pub exp: usize,
pub token_type: TokenType,
}
/// JWT 认证中间件
pub async fn auth_middleware(
State(state): State<AppState>,
headers: HeaderMap,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
// 1. 提取 Authorization header
let auth_header = headers
) -> Result<Response, ErrorResponse> {
let language = req
.extensions()
.get::<Language>()
.map(|v| v.0.as_str())
.unwrap_or(ZH_CN);
let header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if !auth_header.starts_with("Bearer ") {
return Err(StatusCode::UNAUTHORIZED);
}
let token = &auth_header[7..];
// 2. 验证 JWT
let jwt_secret = &state.config.auth.jwt_secret;
let token_data = decode::<Claims>(
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
ErrorResponse::unauthorized(message(
language,
"缺少认证请求头",
"Missing authorization header",
))
})?;
let token = header
.strip_prefix("Bearer ")
.filter(|v| !v.is_empty())
.ok_or_else(|| {
ErrorResponse::unauthorized(message(
language,
"认证格式无效",
"Invalid authorization format",
))
})?;
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// 3. 将 user_id 添加到请求扩展
req.extensions_mut().insert(token_data.claims.sub);
.map_err(|_| {
ErrorResponse::unauthorized(message(
language,
"令牌无效或已过期",
"Token is invalid or expired",
))
})?
.claims;
if claims.token_type != TokenType::Access {
return Err(ErrorResponse::unauthorized(message(
language,
"令牌类型无效",
"Invalid token type",
)));
}
let user = UserRepository::new(state.pool.clone())
.find_by_id_raw(&claims.sub)
.await
.map_err(|_| {
ErrorResponse::internal(message(language, "验证用户失败", "Failed to verify user"))
})?;
if user.map(|v| v.deleted_at.is_some()).unwrap_or(true) {
return Err(ErrorResponse::unauthorized(message(
language,
"用户不存在或已删除",
"User not found or deleted",
)));
}
req.extensions_mut().insert(UserId(claims.sub));
Ok(next.run(req).await)
}
+19
View File
@@ -0,0 +1,19 @@
use crate::error::ErrorResponse;
use axum::{
body::HttpBody,
extract::{Request, State},
middleware::Next,
response::{IntoResponse, Response},
};
pub async fn enforce_body_limit(State(limit): State<usize>, req: Request, next: Next) -> Response {
if req
.body()
.size_hint()
.upper()
.is_some_and(|size| size > limit as u64)
{
return ErrorResponse::payload_too_large("request body too large").into_response();
}
next.run(req).await
}
+43
View File
@@ -0,0 +1,43 @@
use crate::utils::i18n::{EN, ZH_CN};
use async_trait::async_trait;
use axum::{
extract::{FromRequestParts, Request},
http::{request::Parts, StatusCode},
middleware::Next,
response::Response,
};
use std::ops::Deref;
#[derive(Debug, Clone)]
pub struct Language(pub String);
impl Deref for Language {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for Language {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
parts.extensions.get::<Language>().cloned().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"language context missing",
))
}
}
pub async fn language_middleware(mut req: Request, next: Next) -> Response {
let language = req
.headers()
.get("Accept-Language")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.filter(|v| *v == ZH_CN || *v == EN)
.unwrap_or(ZH_CN)
.to_string();
req.extensions_mut().insert(Language(language));
next.run(req).await
}
+62 -65
View File
@@ -1,71 +1,68 @@
use axum::{extract::Request, response::Response};
use axum::{
body::{to_bytes, Body, Bytes},
extract::Request,
middleware::Next,
response::Response,
};
use std::time::Instant;
/// Request ID 标记
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct RequestId(pub String);
/// 请求日志中间件
pub async fn request_logging_middleware(
mut req: Request,
next: axum::middleware::Next,
) -> Response {
let start = Instant::now();
// 提取请求信息
let method = req.method().clone();
let path = req.uri().path().to_string();
let query = req.uri().query().map(|s| s.to_string());
// 生成请求 ID
let request_id = uuid::Uuid::new_v4().to_string();
// 将 request_id 存储到请求扩展中
req.extensions_mut().insert(RequestId(request_id.clone()));
// 第1条日志:请求开始
let separator = "=".repeat(80);
let header = format!("{} {}", method, path);
tracing::info!("{}", separator);
tracing::info!("{}", header);
tracing::info!("{}", separator);
let now_beijing = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
let query_str = query.as_deref().unwrap_or("");
tracing::info!(
"[{}] 📥 查询参数: {} | 时间: {}",
request_id,
query_str,
now_beijing
);
// 调用下一个处理器
let response = next.run(req).await;
// 第3条日志:请求完成
let duration = start.elapsed();
let status = response.status();
tracing::info!(
"[{}] ✅ 状态码: {} | 耗时: {}ms",
request_id,
status.as_u16(),
duration.as_millis()
);
tracing::info!("{}", separator);
response
}
/// 请求日志辅助工具
pub fn log_info<T: std::fmt::Debug>(request_id: &RequestId, label: &str, data: T) {
let data_str = format!("{:?}", data);
let truncated = if data_str.len() > 300 {
format!("{}...", &data_str[..300])
pub fn truncate_string(value: &str, max: usize) -> String {
if value.chars().count() > max {
value.chars().take(max).collect::<String>() + "....."
} else {
data_str
};
tracing::info!("[{}] 🔧 {} | {}", request_id.0, label, truncated);
value.to_string()
}
}
fn truncate_json(value: &mut serde_json::Value, max: usize) {
match value {
serde_json::Value::String(v) => *v = truncate_string(v, max),
serde_json::Value::Array(v) => v.iter_mut().for_each(|v| truncate_json(v, max)),
serde_json::Value::Object(v) => v.values_mut().for_each(|v| truncate_json(v, max)),
_ => {}
}
}
fn pretty(bytes: &Bytes) -> String {
let raw = String::from_utf8_lossy(bytes);
match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(mut value) => {
truncate_json(&mut value, 50);
serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.into_owned())
}
Err(_) => truncate_string(&raw, 50),
}
}
pub async fn request_logging_middleware(mut req: Request<Body>, next: Next) -> Response {
let started = Instant::now();
let id = uuid::Uuid::new_v4().to_string();
let method = req.method().clone();
let uri = req.uri().clone();
req.extensions_mut().insert(RequestId(id.clone()));
let (parts, body) = req.into_parts();
let request_bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
tracing::info!(request_id=%id, %method, %uri, body=%pretty(&request_bytes), "request started");
let response = next
.run(Request::from_parts(parts, Body::from(request_bytes)))
.await;
let status = response.status();
let (parts, body) = response.into_parts();
let response_bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
tracing::info!(request_id=%id, status=%status, elapsed_ms=started.elapsed().as_millis(), body=%pretty(&response_bytes), "request completed");
Response::from_parts(parts, Body::from(response_bytes))
}
pub fn log_info<T: std::fmt::Debug>(request_id: &RequestId, label: &str, data: T) {
tracing::info!(request_id=%request_id.0, %label, data=?data);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncates_at_character_boundary() {
assert_eq!(truncate_string("中文测试", 2), "中文.....");
}
}
+8
View File
@@ -1,2 +1,10 @@
pub mod auth;
pub mod body_limit;
pub mod language;
pub mod logging;
pub mod rate_limit;
pub mod security;
pub mod user_id;
pub use language::Language;
pub use user_id::UserId;
+64
View File
@@ -0,0 +1,64 @@
use axum::{
extract::{ConnectInfo, Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use std::{
collections::HashMap,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct RateLimiter {
limit: u32,
entries: Arc<Mutex<HashMap<String, (Instant, u32)>>>,
}
impl RateLimiter {
pub fn new(limit: u32) -> Self {
Self {
limit,
entries: Arc::new(Mutex::new(HashMap::new())),
}
}
async fn allow(&self, key: String) -> bool {
let mut entries = self.entries.lock().await;
let value = entries.entry(key).or_insert((Instant::now(), 0));
if value.0.elapsed() >= Duration::from_secs(60) {
*value = (Instant::now(), 0);
}
if value.1 >= self.limit {
false
} else {
value.1 += 1;
true
}
}
}
pub async fn rate_limit_middleware(
State(limiter): State<RateLimiter>,
req: Request,
next: Next,
) -> Response {
let key = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|v| v.0.ip().to_string())
.or_else(|| {
req.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.map(str::to_string)
})
.unwrap_or_else(|| "unknown".into());
if limiter.allow(key).await {
next.run(req).await
} else {
(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response()
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::error::ErrorResponse;
use axum::response::IntoResponse;
use axum::{
extract::Request,
http::{
header::{HeaderName, HeaderValue},
StatusCode,
},
middleware::Next,
response::Response,
};
pub async fn security_headers(req: Request, next: Next) -> Response {
let mut response = next.run(req).await;
let headers = response.headers_mut();
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
headers.insert(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
response
}
pub async fn fallback_404() -> Response {
ErrorResponse::not_found("route not found").into_response()
}
pub async fn fallback_405() -> Response {
ErrorResponse {
status: StatusCode::METHOD_NOT_ALLOWED,
message: "method not allowed".into(),
}
.into_response()
}
+28
View File
@@ -0,0 +1,28 @@
use crate::{
error::ErrorResponse,
infra::middleware::Language,
utils::i18n::{message, ZH_CN},
};
use async_trait::async_trait;
use axum::extract::FromRequestParts;
#[derive(Debug, Clone)]
pub struct UserId(pub String);
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for UserId {
type Rejection = ErrorResponse;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_: &S,
) -> Result<Self, Self::Rejection> {
let lang = parts
.extensions
.get::<Language>()
.map(|v| v.0.as_str())
.unwrap_or(ZH_CN);
parts.extensions.get::<UserId>().cloned().ok_or_else(|| {
ErrorResponse::unauthorized(message(lang, "未找到用户身份", "User identity not found"))
})
}
}