refactor(auth): store SessionUser instead of full User in session cache

This commit is contained in:
xfy 2026-06-18 09:55:00 +08:00
parent c780247d17
commit 518b4e5d64
3 changed files with 87 additions and 19 deletions

View File

@ -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<Option<User>, ServerFnError> {
pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, 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<Option<User>, 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<Option<User>, 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<CurrentUserResponse, ServerFnError> {
/// 获取当前登录用户并要求其为 admin否则返回 401/403。
///
/// 供其它服务端接口内部调用。
pub async fn get_current_admin_user() -> Result<User, AppError> {
pub async fn get_current_admin_user() -> Result<SessionUser, AppError> {
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"))]

View File

@ -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<Cache<CacheKey, i64>> = LazyLock::new(|| {
/// 会话用户缓存类型。
#[cfg(feature = "server")]
pub type SessionCache = Cache<String, User>;
pub type SessionCache = Cache<String, SessionUser>;
/// 搜索结果缓存类型。
#[cfg(feature = "server")]
@ -181,7 +181,7 @@ pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
/// 全局会话用户缓存实例,最大容量 1000TTL 5 分钟。
#[cfg(feature = "server")]
static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
pub static SESSION_CACHE: LazyLock<SessionCache> = 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<User> {
pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
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());
}
}

View File

@ -44,6 +44,21 @@ pub struct User {
pub created_at: DateTime<Utc>,
}
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
#[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<Utc>,
}
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicUser {
@ -59,6 +74,32 @@ pub struct PublicUser {
pub created_at: DateTime<Utc>,
}
impl From<User> 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<SessionUser> 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<User> 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"));
}
}