yggdrasil/src/models/user.rs
xfy 53bfb1b7c0 feat(session): invalidate all sessions on role/status change via generation
users 表加 session_generation 列。get_user_by_token 缓存命中后回查 DB
generation,不匹配则逐出缓存并视为未登录,消除降级/封禁用户的权限残留
窗口(H2)。新增 invalidate_user_sessions 用于 bump generation,当前仓库
无运行时角色变更入口,作为未来用户管理功能的基础设施预留。

- 迁移 012:ADD COLUMN IF NOT EXISTS session_generation INT DEFAULT 0
- User/SessionUser 同步加字段,From<User> 如实传递
- 缓存校验走主键查询,亚毫秒级
2026-06-18 13:26:09 +08:00

195 lines
5.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 用户模型。
//!
//! 定义用户角色、内部用户结构体以及可暴露给前端的 PublicUser。
//! User 包含密码哈希等敏感字段PublicUser 用于在 API 中隐藏这些字段。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 用户角色枚举。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UserRole {
/// 管理员,拥有全部后台权限。
Admin,
/// 被禁用的用户,无法登录或操作。
Blocked,
}
impl UserRole {
/// 将数据库中的角色字符串解析为 UserRole无法识别时返回 None。
#[cfg(feature = "server")]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"admin" => Some(UserRole::Admin),
"blocked" => Some(UserRole::Blocked),
_ => None,
}
}
}
/// 内部使用的完整用户结构体,包含敏感字段。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
/// 用户主键。
pub id: i32,
/// 用户名,用于登录与展示。
pub username: String,
/// 邮箱地址。
pub email: String,
/// Argon2 密码哈希,不允许直接序列化返回给前端。
pub password_hash: String,
/// 用户角色。
pub role: UserRole,
/// 账户创建时间。
pub created_at: DateTime<Utc>,
/// 会话世代号,角色/封禁变更时 +1 使旧 session 失效。
pub session_generation: i32,
}
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
#[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>,
/// 会话世代号,签发 session 时记录;与 users 表当前值不一致则 session 失效。
pub session_generation: i32,
}
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicUser {
/// 用户主键。
pub id: i32,
/// 用户名。
pub username: String,
/// 邮箱地址。
pub email: String,
/// 用户角色。
pub role: UserRole,
/// 账户创建时间。
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,
session_generation: u.session_generation,
}
}
}
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 {
PublicUser {
id: u.id,
username: u.username,
email: u.email,
role: u.role,
created_at: u.created_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn sample_user() -> User {
User {
id: 1,
username: "admin".to_string(),
email: "admin@test.com".to_string(),
password_hash: "hash".to_string(),
role: UserRole::Admin,
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
session_generation: 0,
}
}
#[test]
#[cfg(feature = "server")]
fn user_role_from_str() {
assert_eq!(UserRole::from_str("admin"), Some(UserRole::Admin));
assert_eq!(UserRole::from_str("blocked"), Some(UserRole::Blocked));
assert_eq!(UserRole::from_str("unknown"), None);
assert_eq!(UserRole::from_str(""), None);
}
#[test]
fn user_to_public_user_conversion() {
let user = sample_user();
let public: PublicUser = user.clone().into();
assert_eq!(public.id, user.id);
assert_eq!(public.username, user.username);
assert_eq!(public.email, user.email);
assert_eq!(public.role, user.role);
assert_eq!(public.created_at, user.created_at);
}
#[test]
fn public_user_excludes_password_hash() {
let user = sample_user();
let public: PublicUser = user.into();
let json = serde_json::to_string(&public).unwrap();
assert!(!json.contains("password_hash"));
}
#[test]
fn user_role_serde_roundtrip() {
let json = serde_json::to_string(&UserRole::Admin).unwrap();
assert_eq!(
serde_json::from_str::<UserRole>(&json).unwrap(),
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"));
}
}