不灭的焱

革命尚未成功,同志仍须努力 下载Java21

作者:AlbertWen  添加时间:2026-07-06 11:40:57  修改时间:2026-07-07 20:19:10  分类:01.Rust编程  编辑

下面给你一套可落地的 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 工单系统、配置中心、运维平台、资产管理系统等业务。