下面给你一套可落地的 RBAC 方案,技术栈按:Salvo 0.93.0 + Sqlx + MySQL 8.0 + Redis 6。 Salvo 当前 docs.rs 最新版本显示为 0.93.0,发布时间为 2026-05-01;Salvo 的 Handler 既可以作为最终接口处理器,也可以作为中间件,这很适合实现“登录认证中间件 + 权限校验中间件”。(文档.rs)
一、RBAC 实现原理
RBAC,全称是 Role-Based Access Control,基于角色的访问控制。
核心思想是:
用户 User ↓ 绑定 角色 Role ↓ 拥有 权限 Permission ↓ 控制 接口 / 菜单 / 按钮 / 数据操作
例如:
张三是普通员工 张三 → 普通员工角色 普通员工角色 → 只能查看工单 李四是运维主管 李四 → 运维主管角色 运维主管角色 → 查看工单、分派工单、关闭工单、查看报表
在 Web 后端里,RBAC 通常落到接口权限上:
GET /api/users → system:user:list POST /api/users → system:user:create PUT /api/users/:id → system:user:update DELETE /api/users/:id → system:user:delete
请求进来后的判断流程:
1. 客户端请求接口,Header 带 JWT Token 2. Salvo 认证中间件解析 Token,得到 user_id 3. 查询用户是否存在、是否禁用、token_version 是否一致 4. 把当前用户信息放入 Depot 5. 权限中间件拿到 user_id 6. 从 Redis 取该用户权限列表 7. Redis 没有则查 MySQL,再写入 Redis 8. 判断是否包含当前接口要求的 permission_code 9. 有权限 → 放行 10. 无权限 → 返回 403
Salvo 的 Depot 正是用来在一次请求链路中临时存储数据的,例如认证中间件放入当前用户,后续权限中间件和业务 Handler 再读取;官方文档也说明了 Depot 会在一次请求结束后销毁。(Salvo)
二、整体架构设计
推荐架构如下:
前端 / 客户端 ↓ Salvo Router ↓ JWT 认证中间件 AuthMiddleware ↓ RBAC 权限中间件 RequirePermission ↓ 业务 Handler ↓ MySQL 8.0:用户、角色、权限、关联关系 Redis 6:缓存用户权限集合
建议项目目录:
src ├── main.rs ├── config.rs ├── state.rs ├── error.rs ├── models │ ├── mod.rs │ ├── user.rs │ ├── role.rs │ └── permission.rs ├── middleware │ ├── mod.rs │ ├── auth.rs │ └── rbac.rs ├── services │ ├── mod.rs │ ├── auth_service.rs │ └── permission_service.rs ├── handlers │ ├── mod.rs │ ├── auth_handler.rs │ └── user_handler.rs └── routes.rs
三、MySQL 8.0 表结构设计
1. 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(64) NOT NULL COMMENT '登录账号',
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
nickname VARCHAR(64) NULL COMMENT '昵称',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
is_super_admin TINYINT NOT NULL DEFAULT 0 COMMENT '是否超级管理员:1是,0否',
token_version BIGINT NOT NULL DEFAULT 0 COMMENT 'Token版本,用于强制失效旧Token',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_sys_user_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
2. 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID',
code VARCHAR(64) NOT NULL COMMENT '角色编码,如 admin、ops_manager',
name VARCHAR(64) NOT NULL COMMENT '角色名称',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
remark VARCHAR(255) NULL COMMENT '备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_sys_role_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';
3. 权限表
权限建议统一管理接口、菜单、按钮:
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '权限ID',
code VARCHAR(128) NOT NULL COMMENT '权限编码,如 system:user:list',
name VARCHAR(64) NOT NULL COMMENT '权限名称',
perm_type VARCHAR(16) NOT NULL COMMENT '权限类型:API、MENU、BUTTON',
method VARCHAR(16) NULL COMMENT '接口方法,如 GET、POST、PUT、DELETE',
path VARCHAR(255) NULL COMMENT '接口路径,如 /api/users',
parent_id BIGINT NULL COMMENT '父权限ID,菜单树使用',
sort_no INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_sys_permission_code (code),
KEY idx_sys_permission_parent_id (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统权限表';
4. 用户角色关联表
CREATE TABLE sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
role_id BIGINT NOT NULL COMMENT '角色ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_user_role (user_id, role_id),
KEY idx_user_role_role_id (role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
5. 角色权限关联表
CREATE TABLE sys_role_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL COMMENT '角色ID',
permission_id BIGINT NOT NULL COMMENT '权限ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_role_permission (role_id, permission_id),
KEY idx_role_permission_permission_id (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
四、初始化示例数据
INSERT INTO sys_user
(username, password_hash, nickname, status, is_super_admin, token_version)
VALUES
('admin', '$argon2id$v=19$m=19456,t=2,p=1$xxxx$xxxx', '系统管理员', 1, 1, 0),
('ops01', '$argon2id$v=19$m=19456,t=2,p=1$xxxx$xxxx', '运维工程师', 1, 0, 0);
INSERT INTO sys_role
(code, name, status, remark)
VALUES
('admin', '系统管理员', 1, '拥有全部权限'),
('ops_engineer', '运维工程师', 1, '负责日常IT运维');
INSERT INTO sys_permission
(code, name, perm_type, method, path)
VALUES
('system:user:list', '用户列表', 'API', 'GET', '/api/users'),
('system:user:create', '新增用户', 'API', 'POST', '/api/users'),
('system:user:update', '修改用户', 'API', 'PUT', '/api/users/:id'),
('system:user:delete', '删除用户', 'API', 'DELETE', '/api/users/:id'),
('system:role:list', '角色列表', 'API', 'GET', '/api/roles'),
('system:permission:list', '权限列表', 'API', 'GET', '/api/permissions');
-- 绑定用户角色
INSERT INTO sys_user_role(user_id, role_id)
VALUES
(1, 1),
(2, 2);
-- 给运维工程师分配部分权限
INSERT INTO sys_role_permission(role_id, permission_id)
SELECT 2, id FROM sys_permission
WHERE code IN ('system:user:list', 'system:role:list');
五、Redis 缓存设计
Redis 只做权限缓存,不做最终权限来源。
MySQL 是最终可信数据源,Redis 是加速层。
推荐 key:
rbac:user:{user_id}:perms
示例:
rbac:user:2:perms = ["system:user:list", "system:role:list"] TTL = 1800 秒
权限变更时删除缓存:
用户角色变化 → DEL rbac:user:{user_id}:perms
角色权限变化 → 查询该角色下所有 user_id,然后逐个 DEL
用户禁用/改密码 → token_version + 1,同时 DEL rbac:user:{user_id}:perms
不建议把权限长期写死在 JWT 里,因为角色权限一变,旧 Token 里的权限不会自动变化。JWT 里只放:
user_id username token_version exp
六、Cargo 依赖建议
Salvo 这里按 0.93 写。Salvo 的 affix-state 可以把共享状态注入 Depot,适合放数据库连接池、Redis 连接、配置等;官方文档也把数据库连接池和缓存实例列为典型场景。(Salvo)
[dependencies]
salvo = { version = "0.93", features = ["affix-state", "anyhow"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "2"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "macros", "chrono"] }
redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] }
jsonwebtoken = "9"
argon2 = "0.5"
time = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"
七、核心代码示例
1. AppState
// src/state.rs
use redis::aio::ConnectionManager;
use sqlx::MySqlPool;
#[derive(Clone)]
pub struct AppState {
pub db: MySqlPool,
pub redis: ConnectionManager,
pub jwt_secret: String,
}
2. 当前登录用户模型
// src/models/user.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentUser {
pub id: i64,
pub username: String,
pub is_super_admin: bool,
pub token_version: i64,
}
#[derive(Debug, sqlx::FromRow)]
pub struct DbUser {
pub id: i64,
pub username: String,
pub password_hash: String,
pub status: i8,
pub is_super_admin: i8,
pub token_version: i64,
}
3. JWT Claims
// src/models/auth.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: i64,
pub username: String,
pub token_version: i64,
pub exp: usize,
}
4. 统一返回格式
// src/error.rs
use salvo::prelude::*;
use serde_json::json;
pub fn json_error(res: &mut Response, status: StatusCode, code: &str, message: &str) {
res.status_code(status);
res.render(Json(json!({
"code": code,
"message": message
})));
}
5. 登录接口
// src/handlers/auth_handler.rs
use argon2::{
password_hash::{PasswordHash, PasswordVerifier},
Argon2,
};
use jsonwebtoken::{encode, EncodingKey, Header};
use salvo::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::{Duration, OffsetDateTime};
use crate::models::auth::JwtClaims;
use crate::models::user::DbUser;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct LoginReq {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResp {
pub token: String,
}
#[handler]
pub async fn login(req: &mut Request, depot: &mut Depot, res: &mut Response) {
let state = depot.obtain::<AppState>().unwrap().clone();
let body = match req.parse_json::<LoginReq>().await {
Ok(v) => v,
Err(_) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(Json(json!({
"code": "BAD_REQUEST",
"message": "请求参数错误"
})));
return;
}
};
let user = match sqlx::query_as::<_, DbUser>(
r#"
SELECT id, username, password_hash, status, is_super_admin, token_version
FROM sys_user
WHERE username = ?
"#,
)
.bind(&body.username)
.fetch_optional(&state.db)
.await
{
Ok(Some(user)) => user,
_ => {
res.status_code(StatusCode::UNAUTHORIZED);
res.render(Json(json!({
"code": "LOGIN_FAILED",
"message": "账号或密码错误"
})));
return;
}
};
if user.status != 1 {
res.status_code(StatusCode::FORBIDDEN);
res.render(Json(json!({
"code": "USER_DISABLED",
"message": "账号已被禁用"
})));
return;
}
let parsed_hash = match PasswordHash::new(&user.password_hash) {
Ok(v) => v,
Err(_) => {
res.status_code(StatusCode::UNAUTHORIZED);
res.render(Json(json!({
"code": "LOGIN_FAILED",
"message": "账号或密码错误"
})));
return;
}
};
let password_ok = Argon2::default()
.verify_password(body.password.as_bytes(), &parsed_hash)
.is_ok();
if !password_ok {
res.status_code(StatusCode::UNAUTHORIZED);
res.render(Json(json!({
"code": "LOGIN_FAILED",
"message": "账号或密码错误"
})));
return;
}
let exp = (OffsetDateTime::now_utc() + Duration::hours(8)).unix_timestamp() as usize;
let claims = JwtClaims {
sub: user.id,
username: user.username,
token_version: user.token_version,
exp,
};
let token = match encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
) {
Ok(token) => token,
Err(_) => {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(Json(json!({
"code": "TOKEN_CREATE_FAILED",
"message": "Token生成失败"
})));
return;
}
};
res.render(Json(json!({
"code": "OK",
"message": "登录成功",
"data": LoginResp { token }
})));
}
八、认证中间件:解析 JWT
Salvo 官方也提供 jwt-auth 特性,并说明 JWT 的典型流程是登录后生成 Token,客户端后续请求把 Token 放到 Authorization 头,服务端验证后放行。(Salvo) 不过企业 RBAC 场景里,通常还要查 MySQL 用户状态、token_version、Redis 权限缓存,所以这里建议自己写认证中间件,控制更灵活。
// src/middleware/auth.rs
use jsonwebtoken::{decode, DecodingKey, Validation};
use salvo::prelude::*;
use crate::error::json_error;
use crate::models::auth::JwtClaims;
use crate::models::user::CurrentUser;
use crate::state::AppState;
#[handler]
pub async fn auth_middleware(
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) {
let state = depot.obtain::<AppState>().unwrap().clone();
let auth_header = req.header::<String>("authorization")
.or_else(|| req.header::<String>("Authorization"));
let token = match auth_header {
Some(value) if value.starts_with("Bearer ") => {
value.trim_start_matches("Bearer ").trim().to_string()
}
_ => {
json_error(res, StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "缺少Token");
ctrl.skip_rest();
return;
}
};
let token_data = match decode::<JwtClaims>(
&token,
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&Validation::default(),
) {
Ok(data) => data,
Err(_) => {
json_error(res, StatusCode::UNAUTHORIZED, "INVALID_TOKEN", "Token无效或已过期");
ctrl.skip_rest();
return;
}
};
let claims = token_data.claims;
let row = sqlx::query_as::<_, (i64, String, i8, i8, i64)>(
r#"
SELECT id, username, status, is_super_admin, token_version
FROM sys_user
WHERE id = ?
"#,
)
.bind(claims.sub)
.fetch_optional(&state.db)
.await;
let Some((id, username, status, is_super_admin, token_version)) = row.ok().flatten() else {
json_error(res, StatusCode::UNAUTHORIZED, "USER_NOT_FOUND", "用户不存在");
ctrl.skip_rest();
return;
};
if status != 1 {
json_error(res, StatusCode::FORBIDDEN, "USER_DISABLED", "用户已被禁用");
ctrl.skip_rest();
return;
}
if token_version != claims.token_version {
json_error(res, StatusCode::UNAUTHORIZED, "TOKEN_EXPIRED", "Token已失效,请重新登录");
ctrl.skip_rest();
return;
}
let current_user = CurrentUser {
id,
username,
is_super_admin: is_super_admin == 1,
token_version,
};
depot.insert("current_user", current_user);
ctrl.call_next(req, depot, res).await;
}
九、权限查询服务:Redis 优先,MySQL 兜底
// src/services/permission_service.rs
use std::collections::HashSet;
use anyhow::Result;
use redis::AsyncCommands;
use crate::state::AppState;
pub async fn get_user_permission_codes(
state: &AppState,
user_id: i64,
) -> Result<HashSet<String>> {
let cache_key = format!("rbac:user:{}:perms", user_id);
let mut redis = state.redis.clone();
let cached: Option<String> = redis.get(&cache_key).await.unwrap_or(None);
if let Some(json_text) = cached {
let list: Vec<String> = serde_json::from_str(&json_text)?;
return Ok(list.into_iter().collect());
}
let list = sqlx::query_scalar::<_, String>(
r#"
SELECT DISTINCT p.code
FROM sys_permission p
INNER JOIN sys_role_permission rp ON rp.permission_id = p.id
INNER JOIN sys_role r ON r.id = rp.role_id
INNER JOIN sys_user_role ur ON ur.role_id = r.id
INNER JOIN sys_user u ON u.id = ur.user_id
WHERE u.id = ?
AND u.status = 1
AND r.status = 1
AND p.status = 1
"#,
)
.bind(user_id)
.fetch_all(&state.db)
.await?;
let json_text = serde_json::to_string(&list)?;
let _: () = redis
.set_ex(&cache_key, json_text, 1800)
.await
.unwrap_or(());
Ok(list.into_iter().collect())
}
pub async fn clear_user_permission_cache(state: &AppState, user_id: i64) -> Result<()> {
let cache_key = format!("rbac:user:{}:perms", user_id);
let mut redis = state.redis.clone();
let _: () = redis.del(cache_key).await?;
Ok(())
}
十、权限中间件:RequirePermission
Salvo 的中间件通过 hoop 加到 Router 上,而且会影响当前 Router 及其子 Router;Handler 执行链中,前面的中间件可以调用 ctrl.call_next() 放行后续 Handler。(Salvo)
// src/middleware/rbac.rs
use salvo::prelude::*;
use crate::error::json_error;
use crate::models::user::CurrentUser;
use crate::services::permission_service::get_user_permission_codes;
use crate::state::AppState;
pub struct RequirePermission {
code: &'static str,
}
impl RequirePermission {
pub fn new(code: &'static str) -> Self {
Self { code }
}
}
#[async_trait]
impl Handler for RequirePermission {
async fn handle(
&self,
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) {
let state = depot.obtain::<AppState>().unwrap().clone();
let Some(current_user) = depot.get::<CurrentUser>("current_user").cloned() else {
json_error(res, StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "用户未登录");
ctrl.skip_rest();
return;
};
if current_user.is_super_admin {
ctrl.call_next(req, depot, res).await;
return;
}
let perms = match get_user_permission_codes(&state, current_user.id).await {
Ok(perms) => perms,
Err(_) => {
json_error(res, StatusCode::INTERNAL_SERVER_ERROR, "PERMISSION_LOAD_FAILED", "权限加载失败");
ctrl.skip_rest();
return;
}
};
if !perms.contains(self.code) {
json_error(res, StatusCode::FORBIDDEN, "FORBIDDEN", "没有访问权限");
ctrl.skip_rest();
return;
}
ctrl.call_next(req, depot, res).await;
}
}
十一、业务接口示例
// src/handlers/user_handler.rs
use salvo::prelude::*;
use serde_json::json;
use crate::models::user::CurrentUser;
#[handler]
pub async fn list_users(depot: &mut Depot, res: &mut Response) {
let current_user = depot.get::<CurrentUser>("current_user").unwrap();
res.render(Json(json!({
"code": "OK",
"message": "查询成功",
"current_user": current_user,
"data": [
{
"id": 1,
"username": "admin",
"nickname": "系统管理员"
},
{
"id": 2,
"username": "ops01",
"nickname": "运维工程师"
}
]
})));
}
#[handler]
pub async fn create_user(res: &mut Response) {
res.render(Json(json!({
"code": "OK",
"message": "新增用户成功"
})));
}
十二、路由配置
// src/routes.rs
use salvo::prelude::*;
use crate::handlers::auth_handler::login;
use crate::handlers::user_handler::{create_user, list_users};
use crate::middleware::auth::auth_middleware;
use crate::middleware::rbac::RequirePermission;
pub fn build_routes() -> Router {
Router::new()
// 不需要登录的接口
.push(
Router::with_path("api/auth/login")
.post(login)
)
// 需要登录的接口
.push(
Router::with_path("api")
.hoop(auth_middleware)
// GET /api/users
.push(
Router::with_path("users")
.hoop(RequirePermission::new("system:user:list"))
.get(list_users)
)
// POST /api/users
.push(
Router::with_path("users")
.hoop(RequirePermission::new("system:user:create"))
.post(create_user)
)
)
}
十三、main.rs 启动代码
// src/main.rs
mod error;
mod routes;
mod state;
mod models {
pub mod auth;
pub mod user;
}
mod middleware {
pub mod auth;
pub mod rbac;
}
mod services {
pub mod permission_service;
}
mod handlers {
pub mod auth_handler;
pub mod user_handler;
}
use redis::Client;
use salvo::prelude::*;
use sqlx::mysql::MySqlPoolOptions;
use crate::routes::build_routes;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().init();
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "mysql://root:123456@127.0.0.1:3306/salvo_rbac".to_string());
let redis_url = std::env::var("REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379/".to_string());
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "change_me_to_a_long_random_secret".to_string());
let db = MySqlPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await?;
let redis_client = Client::open(redis_url)?;
let redis = redis_client.get_connection_manager().await?;
let app_state = AppState {
db,
redis,
jwt_secret,
};
let router = build_routes()
.hoop(salvo::affix_state::inject(app_state));
let acceptor = TcpListener::new("0.0.0.0:5800").bind().await;
Server::new(acceptor).serve(router).await;
Ok(())
}
十四、权限变更后的缓存清理
1. 修改某个用户的角色后
use crate::services::permission_service::clear_user_permission_cache;
pub async fn update_user_roles(state: &AppState, user_id: i64, role_ids: Vec<i64>) -> anyhow::Result<()> {
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM sys_user_role WHERE user_id = ?")
.bind(user_id)
.execute(&mut *tx)
.await?;
for role_id in role_ids {
sqlx::query("INSERT INTO sys_user_role(user_id, role_id) VALUES (?, ?)")
.bind(user_id)
.bind(role_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
clear_user_permission_cache(state, user_id).await?;
Ok(())
}
2. 修改角色权限后
pub async fn clear_role_users_permission_cache(
state: &AppState,
role_id: i64,
) -> anyhow::Result<()> {
let user_ids = sqlx::query_scalar::<_, i64>(
"SELECT user_id FROM sys_user_role WHERE role_id = ?",
)
.bind(role_id)
.fetch_all(&state.db)
.await?;
for user_id in user_ids {
crate::services::permission_service::clear_user_permission_cache(state, user_id).await?;
}
Ok(())
}
3. 禁用用户或修改密码后,让旧 Token 失效
UPDATE sys_user
SET status = 0,
token_version = token_version + 1
WHERE id = ?;
或者修改密码:
UPDATE sys_user
SET password_hash = ?,
token_version = token_version + 1
WHERE id = ?;
这样即使旧 JWT 还没过期,因为里面的 token_version 和数据库不一致,也会被认证中间件拦截。
十五、接口权限编码规范
建议统一格式:
模块:资源:动作
例如:
system:user:list system:user:create system:user:update system:user:delete system:role:list system:role:create system:role:update system:role:delete ticket:order:list ticket:order:create ticket:order:assign ticket:order:close network:device:list network:device:update network:device:monitor
不要直接用 URL 当权限编码。
不推荐:
GET:/api/users POST:/api/users
推荐:
system:user:list system:user:create
原因是权限编码应该稳定,URL 后续可能调整,但权限语义不应频繁变化。
十六、推荐落地流程
第一步:先实现认证
先做:
登录接口 JWT 生成 JWT 认证中间件 当前用户写入 Depot
确认可以做到:
未登录访问 /api/users → 401 登录后访问 /api/users → 能进入业务接口
第二步:实现 RBAC 基础表
落库:
sys_user sys_role sys_permission sys_user_role sys_role_permission
先手动插入一批测试数据。
第三步:实现权限查询
实现:
get_user_permission_codes(user_id)
先直接查 MySQL,不加 Redis。
确认:
ops01 的权限列表 = ["system:user:list", "system:role:list"]
第四步:接入权限中间件
接口上挂权限:
Router::with_path("users")
.hoop(RequirePermission::new("system:user:list"))
.get(list_users)
确认:
有权限 → 200 无权限 → 403
第五步:加入 Redis 缓存
缓存用户权限列表:
rbac:user:{user_id}:perms
确认:
第一次查 MySQL 第二次查 Redis 权限变更后清缓存
第六步:做后台管理页面或接口
最少需要这些管理接口:
用户管理 角色管理 权限管理 用户分配角色 角色分配权限
后台页面可以按这个顺序设计:
系统管理 ├── 用户管理 ├── 角色管理 └── 权限管理
十七、生产环境注意事项
1. 密码必须用 Argon2 或 bcrypt
不要存明文密码,也不要用 MD5。
推荐:
argon2id
2. JWT Secret 必须放环境变量
不要写死在代码里:
export JWT_SECRET="一段足够长的随机字符串"
3. 超级管理员建议走数据库字段
is_super_admin = 1
超级管理员直接跳过权限判断。
4. 权限缓存要有失效机制
不要只依赖 TTL。
必须在这些动作后删除缓存:
用户分配角色 角色分配权限 用户禁用 用户删除 角色禁用 权限禁用
5. Token 要支持强制失效
使用:
token_version
用户修改密码、禁用账号、踢下线时:
UPDATE sys_user SET token_version = token_version + 1 WHERE id = ?;
十八、最终推荐方案总结
你可以按这个方案落地:
认证方式:
JWT
权限模型:
用户 - 角色 - 权限
权限粒度:
接口权限为主,菜单和按钮权限可复用同一张 sys_permission 表
权限编码:
system:user:list 这种业务语义编码
权限校验位置:
Salvo 自定义 Handler 中间件
当前用户传递:
Salvo Depot
权限缓存:
Redis,key 为 rbac:user:{user_id}:perms
最终数据源:
MySQL 8.0
权限变更:
更新 MySQL 后主动删除 Redis 缓存
旧 Token 失效:
sys_user.token_version
核心调用链就是:
Router
→ auth_middleware
→ RequirePermission("system:user:list")
→ list_users
这套方案适合中后台系统、IT 工单系统、配置中心、运维平台、资产管理系统等业务。