From 518b4e5d6465799b478d837064ebff6627fecb3c Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 18 Jun 2026 09:55:00 +0800 Subject: [PATCH] refactor(auth): store SessionUser instead of full User in session cache --- src/api/auth.rs | 19 +++++++-------- src/cache.rs | 26 +++++++++++++------- src/models/user.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index b36c16c..dac8204 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -20,7 +20,7 @@ use crate::auth::{password, session}; #[cfg(feature = "server")] use crate::db::pool::get_conn; #[cfg(feature = "server")] -use crate::models::user::{User, UserRole}; +use crate::models::user::{SessionUser, UserRole}; use crate::models::user::PublicUser; #[cfg(feature = "server")] @@ -315,11 +315,11 @@ pub struct CurrentUserResponse { } #[cfg(feature = "server")] -/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。 +/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。 /// /// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。 /// 仅服务端内部使用,不会暴露给前端。 -pub async fn get_user_by_token(token: &str) -> Result, ServerFnError> { +pub async fn get_user_by_token(token: &str) -> Result, ServerFnError> { let token_hash = session::hash_token(token); if let Some(user) = crate::cache::get_session_user(&token_hash).await { @@ -330,7 +330,7 @@ pub async fn get_user_by_token(token: &str) -> Result, ServerFnErro let row = client .query_opt( - "SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at + "SELECT u.id, u.username, u.email, u.role, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = $1 AND s.expires_at > NOW()", @@ -343,11 +343,10 @@ pub async fn get_user_by_token(token: &str) -> Result, ServerFnErro Some(row) => { let role_str: String = row.get("role"); let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked); - Some(User { + Some(SessionUser { id: row.get("id"), username: row.get("username"), email: row.get("email"), - password_hash: row.get("password_hash"), role, created_at: row.get("created_at"), }) @@ -381,19 +380,19 @@ pub async fn get_current_user() -> Result { /// 获取当前登录用户并要求其为 admin,否则返回 401/403。 /// /// 供其它服务端接口内部调用。 -pub async fn get_current_admin_user() -> Result { +pub async fn get_current_admin_user() -> Result { let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; - let user = get_user_by_token(&token) + let session_user = get_user_by_token(&token) .await .map_err(AppError::query)? .ok_or(AppError::Unauthorized("会话已过期"))?; - if user.role != UserRole::Admin { + if session_user.role != UserRole::Admin { return Err(AppError::Forbidden("权限不足")); } - Ok(user) + Ok(session_user) } #[cfg(all(test, feature = "server"))] diff --git a/src/cache.rs b/src/cache.rs index 70f5e79..098898d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -16,7 +16,7 @@ use crate::models::comment::PublicComment; #[cfg(feature = "server")] use crate::models::post::{Post, PostListItem, PostStats, Tag}; #[cfg(feature = "server")] -use crate::models::user::User; +use crate::models::user::SessionUser; // ============================================================================ // 缓存 TTL 配置 @@ -173,7 +173,7 @@ static PENDING_COUNT_CACHE: LazyLock> = LazyLock::new(|| { /// 会话用户缓存类型。 #[cfg(feature = "server")] -pub type SessionCache = Cache; +pub type SessionCache = Cache; /// 搜索结果缓存类型。 #[cfg(feature = "server")] @@ -181,7 +181,7 @@ pub type SearchCache = Cache, i64)>; /// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。 #[cfg(feature = "server")] -static SESSION_CACHE: LazyLock = LazyLock::new(|| { +pub static SESSION_CACHE: LazyLock = LazyLock::new(|| { Cache::builder() .max_capacity(1000) .time_to_live(TTL_SESSION) @@ -389,13 +389,13 @@ pub fn normalize_search_key(query: &str) -> String { /// 读取会话用户缓存。 #[cfg(feature = "server")] -pub async fn get_session_user(token_hash: &str) -> Option { +pub async fn get_session_user(token_hash: &str) -> Option { SESSION_CACHE.get(token_hash).await } /// 写入会话用户缓存。 #[cfg(feature = "server")] -pub async fn set_session_user(token_hash: &str, user: User) { +pub async fn set_session_user(token_hash: &str, user: SessionUser) { let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await; } @@ -446,7 +446,7 @@ mod tests { use super::*; use crate::models::comment::PublicComment; use crate::models::post::PostStatus; - use crate::models::user::{User, UserRole}; + use crate::models::user::{SessionUser, UserRole}; use serial_test::serial; #[test] @@ -656,11 +656,10 @@ mod tests { #[tokio::test] #[serial] async fn session_cache_roundtrip() { - let user = User { + let user = SessionUser { id: 42, username: "cached_user".to_string(), email: "cached@example.com".to_string(), - password_hash: "argon2_hash".to_string(), role: UserRole::Admin, created_at: chrono::Utc::now(), }; @@ -674,7 +673,6 @@ mod tests { assert_eq!(cached_user.id, user.id); assert_eq!(cached_user.username, user.username); assert_eq!(cached_user.email, user.email); - assert_eq!(cached_user.password_hash, user.password_hash); assert_eq!(cached_user.role, user.role); invalidate_session_user(token_hash).await; @@ -735,4 +733,14 @@ mod tests { assert!(get_search_results(query).await.is_none()); } + #[tokio::test] + #[serial] + async fn search_cache_invalidation() { + set_search_results("tokio", vec![], 0).await; + assert!(get_search_results("tokio").await.is_some()); + + invalidate_search_results(); + assert!(get_search_results("tokio").await.is_none()); + } + } diff --git a/src/models/user.rs b/src/models/user.rs index f588061..47880aa 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -44,6 +44,21 @@ pub struct User { pub created_at: DateTime, } +/// 会话缓存使用的轻量用户结构体,不含密码哈希。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionUser { + /// 用户主键。 + pub id: i32, + /// 用户名。 + pub username: String, + /// 邮箱地址。 + pub email: String, + /// 用户角色。 + pub role: UserRole, + /// 账户创建时间。 + pub created_at: DateTime, +} + /// 可公开的用户信息,从 User 转换而来,不含密码哈希。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PublicUser { @@ -59,6 +74,32 @@ pub struct PublicUser { pub created_at: DateTime, } +impl From for SessionUser { + /// 将 User 转换为 SessionUser,丢弃 password_hash 字段。 + fn from(u: User) -> Self { + SessionUser { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + created_at: u.created_at, + } + } +} + +impl From for PublicUser { + /// 将 SessionUser 转换为 PublicUser。 + fn from(u: SessionUser) -> Self { + PublicUser { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + created_at: u.created_at, + } + } +} + impl From for PublicUser { /// 将 User 转换为 PublicUser,丢弃 password_hash 字段。 fn from(u: User) -> Self { @@ -124,4 +165,24 @@ mod tests { UserRole::Admin ); } + + #[test] + fn user_to_session_user_excludes_password_hash() { + let user = sample_user(); + let session: SessionUser = user.clone().into(); + assert_eq!(session.id, user.id); + assert_eq!(session.username, user.username); + assert_eq!(session.email, user.email); + assert_eq!(session.role, user.role); + assert_eq!(session.created_at, user.created_at); + } + + #[test] + fn session_user_to_public_user_excludes_password_hash() { + let user = sample_user(); + let session: SessionUser = user.into(); + let public: PublicUser = session.into(); + let json = serde_json::to_string(&public).unwrap(); + assert!(!json.contains("password_hash")); + } }