听说若依挺方便的啊,那我也来试试看

一、环境配置

直接在docker中配置环境,比较方便:

ruoyi-docker-compose.yml:

version: "3.8"

services:
  mysql:
    image: mysql:8.0.36
    container_name: ruoyi-mysql
    restart: always
    environment:
      # root 用户密码改为 root
      MYSQL_ROOT_PASSWORD: root
      # 自动创建数据库 ry
      MYSQL_DATABASE: ry
    ports:
      - "3306:3306"
    volumes:
      # 数据持久化
      - ./docker/mysql/data:/var/lib/mysql
      # 初始化脚本(首次启动时执行)
      - ./docker/mysql/init:/docker-entrypoint-initdb.d
    command:
      --default-authentication-plugin=mysql_native_password

  redis:
    image: redis:7.2
    container_name: ruoyi-redis
    restart: always
    ports:
      - "6379:6379"
    volumes:
      - ./docker/redis/data:/data

networks:
  default:
    name: ruoyi-net

配置好是这样的:

然后拉取文件:https://gitee.com/y_project/RuoYi-Vue/tree/springboot3

拉取好后记得配置数据库,数据库文件在sql文件夹下

配置数据库之后还需要配置好yaml文件,路径是:ruoyi-admin/src/main/resources/application-druid.yml

前端的环境配置我是直接npm install...

之后前后端分别启动起来就好了

直接访问http://localhost就可以看到登录页:

这样前期准备就算完成了。

二、学习源码--验证码(前端)

验证码这块我一直没学会,现在研究看看:
首先查看前端的ruoyi-ui/src/views/login.vue,可以看到验证码的前端实现:

    getCode() {
      getCodeImg().then(res => {
        // 根据后端返回的 captchaEnabled,决定是否启用验证码。
        this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled
        if (this.captchaEnabled) {
          // 如果启用,则将后端返回的 Base64 编码的图片拼成 Data URL 赋给 codeUrl,用于 <img> 标签展示验证码。
          this.codeUrl = "data:image/gif;base64," + res.img
          // 同时将后端生成的 uuid 写入 loginForm.uuid,用于后续登录请求时校验验证码。
          this.loginForm.uuid = res.uuid
        }
      })
    },

因为代码经过了多层嵌套和封装,所以我点进getCodeImg()方法对应的ruoyi-ui/src/api/login.js文件查看:

/**
 * 获取验证码图像
 * 1. 向后端发送 GET 请求,获取验证码图片及相关配置(是否启用、Base64 图片、UUID)
 * 2. 因在登录前调用,此接口无需携带用户 Token
 * 3. 设置超时时间,防止请求长时间挂起
 */
export function getCodeImg() {
  return request({
    // 接口路径:后端提供的验证码生成地址
    url: '/captchaImage',
    // HTTP 方法:GET 用于获取资源
    method: 'get',
    // 自定义请求头,用于拦截器判断是否注入 Token
    headers: {
      // 标记本次请求无需在请求头中携带 Authorization 或其他登录凭证
      isToken: false
    },
    // 请求超时时间(毫秒):20 秒后若无响应则中断并抛出超时错误
    timeout: 20000
  })
}

可以看到,代码又封装了一个request,那我接着往下看ruoyi-ui/src/utils/request.js:

...省略...
// 创建axios实例
const service = axios.create({
  // axios中请求配置有baseURL选项,表示请求URL公共部分,
  // 所有通过该实例发起的相对请求(如 service.get('/xxx'))
  // 会自动拼接成 process.env.VUE_APP_BASE_API + '/xxx'。
  baseURL: process.env.VUE_APP_BASE_API,
  // 超时
  timeout: 10000
})
...省略...

可以看到在这里调用了axios,env.VUE_APP_BASE_API对应的是这三个文件:

点进第一个,可以看到:

# 页面标题
VUE_APP_TITLE = 若依管理系统

# 开发环境配置
ENV = 'development'

# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'

# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

通过这种方式定义了统一的访问api:

所以我们就知道了前端生成验证码的流程,然而,验证码是前端向后端发送请求后得到的,那么就涉及到了跨域问题,此时我们并没有配置nginx,那么若依是怎么做反向代理的呢?

这会我们就可以去看ruoyi-ui/vue.config.js中的proxy:

  // 通过 webpack-dev-server 在本地启动一个可热重载的服务,方便调试。
  devServer: {
    // 监听所有网卡的地址,包括本机 localhost、局域网 IP,方便在不同设备(手机/平板)调试。
    host: '0.0.0.0',
    port: port,
    open: true,
    proxy: {
      // detail: https://cli.vuejs.org/config/#devserver-proxy
      [process.env.VUE_APP_BASE_API]: {
        // webpack-dev-server 内置的 http-proxy-middleware,
        // 会拦截匹配到的请求并转发到 target ('http://localhost:8080')。
        target: baseUrl,
        // changeOrigin: true 会修改请求头中的 Host 字段为目标地址,避免目标服务器拒绝服务。
        changeOrigin: true,
        // pathRewrite 用于重写 URL 路径,将前缀去除后再转发。
        // 如:前端请求 /dev-api/captchaImage -> 代理后转到 http://localhost:8080/captchaImage
        pathRewrite: {
          ['^' + process.env.VUE_APP_BASE_API]: ''
        }
      },
      // 二、针对 springdoc(OpenAPI 文档)的代理
      // 匹配 /v3/api-docs/... 路径,方便在前端查看 Swagger 文档
      '^/v3/api-docs/(.*)': {
        target: baseUrl,
        changeOrigin: true
      }
    },
    disableHostCheck: true
  },

明白了前端流程之后,我们就可以去看看后端是怎么实现的了。

三、学习源码--验证码(后端)

通过万能的idea的全局搜索,搜索captchaImage,我就能找到实现验证码的controller:CaptchaController。

查看代码的getCode方法,我们可以看到一个AjaxResult 类,这个类是用来做Ajax的后端向前端统一返回结果的包装类,里面提供了多种静态工厂方法AjaxResult.success()AjaxResult.success(data)AjaxResult.error(msg)AjaxResult.warn(msg) 等,代码后面定义的put方法使我们能够进行链式调用:

/**
     * 方便链式调用
     *
     * @param key 键
     * @param value 值
     * @return 数据对象
     */
    @Override
    public AjaxResult put(String key, Object value)
    {
        super.put(key, value);
        return this;
    }

AjaxResult 是继承自 HashMap<String, Object> 的类,super.put(key, value) 就是调用了 HashMapput 方法,把键值对放进去。return this; 表示返回当前对象实例(AjaxResult 对象本身),实现链式调用的基础。

链式调用就可以像这样:

result.put("name", "张三").put("age", 18).put("sex", "男");

方法返回自己本身,就可以连着点点点。而为什么这么做呢,因为给前端返回数据时可能需要这样返回:

{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "name": "张三",
    "age": 18
  }
}

然后我就可以直接:

return AjaxResult.success()
                 .put("name", "张三")
                 .put("age", 18);

达到我想要的效果,就比每次 new Map 再 set、再 return 简洁多了。

然后我们回到验证码方法:
生成uuid,在前面加上redis key:captcha_codes: 得到每个用户对应的verifyKey。

接着生成math验证码:

if ("math".equals(captchaType))
{
    String capText = captchaProducerMath.createText(); // e.g. "3+5@8"
    capStr = capText.substring(0, capText.lastIndexOf("@"));  // "3+5"
    code = capText.substring(capText.lastIndexOf("@") + 1);   // "8"
    image = captchaProducerMath.createImage(capStr);
}

可以看到调用的createText()方法创建了一个数学运算,问题和结果使用@进行分割,这个方法是 import com.google.code.kaptcha.Producer;带有的。

生成字符验证码也是同理,略。

生成好验证码之后就存入redis缓存中去,把验证码答案 code ,key 为 verifyKey,并设置有效期(默认2min):

redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);

然后就可以把生成的图片写入内存流,编码为 Base64 字符串。其中FastByteArrayOutputStream是 Spring 工具包提供的,比ByteArrayOutputStream的初始容量更大(256 bytes),动态扩容逻辑更激进(当空间不足时,buf.length << 1(即翻倍)扩容),内存复制更高效。

这样,验证码的前后端的业务流程就看完了。

四、学习源码--登录(前端)

查看login.vue的 handleLogin() 方法:

if (this.loginForm.rememberMe) {
  Cookies.set("username", this.loginForm.username, { expires: 30 })
  Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 })
  Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 })
}

这一部分是当勾选了记住用户名密码时把它们存到Cookies中去。

然后是:

this.$store.dispatch("Login", this.loginForm)

这是Vuex里共享的全局方法,Login Action,点进去可以看到封装好的内容ruoyi-ui/src/store/modules/user.js:

   actions: {
    // 登录
    Login({ commit }, userInfo) {
      const username = userInfo.username.trim()
      const password = userInfo.password
      const code = userInfo.code
      const uuid = userInfo.uuid
      return new Promise((resolve, reject) => {
        login(username, password, code, uuid).then(res => {
          setToken(res.token)
          commit('SET_TOKEN', res.token)
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },

可以看到在这个基础上又封装了一个Promise(Vue的异步处理对象),目的是统一返回自定义的 Promise,使外部调用统一处理。然后调用了ruoyi-ui/src/api/login.js的登录方法:

export function login(username, password, code, uuid) {
  const data = {
    username,
    password,
    code,
    uuid
  }
  return request({
    url: '/login',
    headers: {
      isToken: false,
      repeatSubmit: false
    },
    method: 'post',
    data: data
  })
}

从前两章可以知道,此时前端就通过axios携带data,以post请求,路径为/login发送给后端了。

五、学习源码--登录(后端)

到了后端有3个步骤,都在 loginService.login 方法中实现:

1.校验验证码

public void validateCaptcha(String username, String code, String uuid)
    {
        boolean captchaEnabled = configService.selectCaptchaEnabled();
        if (captchaEnabled)
        {
            // 如果 uuid 不为 null,则返回 uuid 本身。
            // 如果 uuid 为 null,则返回第二个参数(这里是空字符串 "")。
            String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
            // 从缓存获取验证码
            String captcha = redisCache.getCacheObject(verifyKey);
            if (captcha == null)
            {
                // 异步写登录失败日志,提升登录风控能力
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
                throw new CaptchaExpireException();
            }
            // 确保验证码只能使用一次
            redisCache.deleteObject(verifyKey);
            if (!code.equalsIgnoreCase(captcha))
            {
                // 异步写登录失败日志,提升登录风控能力
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
                throw new CaptchaException();
            }
        }
    }

其中,AsyncManager.me()返回一个全局唯一的异步任务管理器对象(采用类似“饿汉式单例”),负责调度异步任务。AsyncFactory.recordLogininfor(...)构建一个 TimerTask 对象(实现了 Runnable 接口),该任务封装了“将登录信息写入数据库和日志”的操作,包括 IP 地址、浏览器信息、操作结果等。.execute(task)将上面生成的 TimerTask 提交给 ScheduledExecutorService 线程池,常带延迟执行(默认大约 10 毫秒)。这样核心逻辑继续执行,日志记录由后台线程异步完成,不会拖慢响应速度。

2.校验用户名和密码

        // 用户验证
        Authentication authentication = null;
        try
        {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());

其中, UsernamePasswordAuthenticationToken 是 Spring Security 提供的一个 Authentication 实现,用于封装用户输入的用户名(principal)和密码(credentials)。

AuthenticationContextHolder.setContext(authenticationToken) 将这个认证请求放入上下文中,方便后续流程访问。Spring Security 默认使用 SecurityContextHolder 存储认证信息,它使用 ThreadLocal 机制,确保每次请求在同一个线程中可访问此上下文。这样做的目的是让认证流程安全且线程隔离,不会产生上下文泄漏。

AuthenticationManager 是 Spring Security 核心认证接口,只有一个方法 authenticate(Authentication),用于处理认证请求。

authenticate(authenticationToken) 的流程:

  • ProviderManager 接收到 UsernamePasswordAuthenticationToken

  • 找到支持这种类型的 AuthenticationProvider(通常是 DaoAuthenticationProvider);

  • 调用 UserDetailsServiceImpl.loadUserByUsername(username) 从数据库加载用户详情;

  • 对比用户输入密码和数据库中存储的加密密码;

  • 认证成功:返回一个包含用户信息和权限的新 Authentication 对象(isAuthenticated()true);
    认证失败:抛出 BadCredentialsExceptionDisabledException 等异常。

AuthenticationContextHolder.clearContext() 作用是清除 Spring Security 在当前线程里的安全上下文(SecurityContext),确保之后不会发生信息泄漏或线程污染。

最后从认证结果中获取已认证的 LoginUser 对象(包含用户全部信息和权限)。调用 recordLoginInfo(userId) 方法,记录用户登录时间、登录 IP、登录设备类型等信息到用户表或日志。其中,AsyncManager记录的是登录日志,而recordLoginInfo 则记录的是用户登录信息(ip和时间等)。

3.生成token

public String createToken(LoginUser loginUser)
    {
        String token = IdUtils.fastUUID();
        loginUser.setToken(token);
        // 获取用户ip,系统,浏览器信息
        setUserAgent(loginUser);
        // 存储用户登录信息到redis,30min
        refreshToken(loginUser);

        // 这里用jwt
        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_USER_KEY, token);
        claims.put(Constants.JWT_USERNAME, loginUser.getUsername());
        return createToken(claims);
    }

这里调用了framework服务里的创建token方法,包含了jwt的工具,使用的依赖是:

<!-- Token生成与解析-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>

六、学习源码--获取用户信息(前端)

在登录时,前端会发送一个 getInfo 的登录请求,我就来找一下这个请求是怎么发出的。

经过一番查找,在 src/permission.js 中的 beforeEach 方法中找到了:

router.beforeEach((to, from, next) => {
  NProgress.start()
  if (getToken()) {
    to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
    /* has token*/
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else if (isWhiteList(to.path)) {
      next()
    } else {
      if (store.getters.roles.length === 0) {
        isRelogin.show = true
        // 判断当前用户是否已拉取完user_info信息
        store.dispatch('GetInfo').then(() => {
          isRelogin.show = false
          store.dispatch('GenerateRoutes').then(accessRoutes => {
            // 根据roles权限生成可访问的路由表
            router.addRoutes(accessRoutes) // 动态添加可访问路由表
            next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
          })
        }).catch(err => {
            store.dispatch('LogOut').then(() => {
              Message.error(err)
              next({ path: '/' })
            })
          })
      } else {
        next()
      }
    }
  } else {
    // 没有token
    if (isWhiteList(to.path)) {
      // 在免登录白名单,直接进入
      next()
    } else {
      next(`/login?redirect=${encodeURIComponent(to.fullPath)}`) // 否则全部重定向到登录页
      NProgress.done()
    }
  }
})

前端每个页面跳转之前都需要进入这个 beforeEach 方法里面,让我们接着看 store.dispatch('GetInfo') 里面的GetInfo,点进去就看见src/store/modules/user.js 里的GetInfo方法,里面有一个Promise异步调用,再进到该异步调用里的getInfo方法里,就可以看到配置跳转连接的地方了:

// 获取用户详细信息
export function getInfo() {
  return request({
    url: '/getInfo',
    method: 'get'
  })
}

等获取到信息后就会把信息存储在Vuex里。

然后我们就可以去看后端是如何处理的。

七、学习源码--获取用户信息(后端)

在后端查找到了 SysLoginController 的getInfo方法:

    @GetMapping("getInfo")
    public AjaxResult getInfo()
    {
        LoginUser loginUser = SecurityUtils.getLoginUser();
        SysUser user = loginUser.getUser();
        // 角色集合
        Set<String> roles = permissionService.getRolePermission(user);
        // 权限集合
        Set<String> permissions = permissionService.getMenuPermission(user);
        if (!loginUser.getPermissions().equals(permissions))
        {
            loginUser.setPermissions(permissions);
            tokenService.refreshToken(loginUser);
        }
        AjaxResult ajax = AjaxResult.success();
        ajax.put("user", user);
        ajax.put("roles", roles);
        ajax.put("permissions", permissions);
        // 默认密码检查:提示用户修改初始密码。
        ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
        // 密码过期检查:强制用户定期更新密码。
        ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
        return ajax;
    }
  1. 用户登录时,Spring Security 的认证过滤器(如ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java)会调用 AuthenticationManager 进行验证。

  2. 验证成功后,生成包含用户信息的 Authentication 对象,并存入 SecurityContextHolder

    SecurityContextHolder.getContext().setAuthentication(authentication);
  3. SecurityContextHolder 默认使用 ThreadLocal 存储 SecurityContext(内含 Authentication 对象),确保每个线程独立保存用户信息

Spring Security处理好后就开始获取用户的权限了,进入 getMenuPermission 方法内可以看见是怎么获取和分配用户权限的:

public Set<String> getMenuPermission(SysUser user)
    {
        Set<String> perms = new HashSet<String>();
        // 管理员拥有所有权限
        if (user.isAdmin())
        {
            perms.add("*:*:*");
        }
        else
        {
            List<SysRole> roles = user.getRoles();
            if (!CollectionUtils.isEmpty(roles))
            {
                // 多角色设置permissions属性,以便数据权限匹配权限
                for (SysRole role : roles)
                {
                    if (StringUtils.equals(role.getStatus(), UserConstants.ROLE_NORMAL) && !role.isAdmin())
                    {
                        Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
                        role.setPermissions(rolePerms);
                        perms.addAll(rolePerms);
                    }
                }
            }
            else
            {
                perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
            }
        }
        return perms;
    }

这段 getMenuPermission 方法是一个典型的基于 RBAC(角色-权限)模型的权限获取逻辑,核心目标是根据用户身份动态计算其菜单权限集合。

  1. *:*:* 是 RBAC 中的通配符权限,通常对应 Spring Security 的 AllPermission 设计,表示无限制访问

  2. 用if判断是为了只处理状态为 ROLE_NORMAL(启用)的角色,避免禁用角色权限泄漏,然后通过 menuService.selectMenuPermsByRoleId 查询角色关联的权限码(如 "system:user:add")。最后使用权限合并机制​--用户最终权限是其所有角色权限的并集(perms.addAll

  3. 如果是未绑定角色的用户,则直接按用户ID查权限

八、学习源码--获取动态菜单路由(前端)

我们接着看 beforeEach 方法中的 GenerateRoutes

// 生成路由
    GenerateRoutes({ commit }) {
      return new Promise(resolve => {
        // 向后端请求路由数据
        getRouters().then(res => {
          // 1. 深拷贝后端返回的路由数据
          const sdata = JSON.parse(JSON.stringify(res.data))
          const rdata = JSON.parse(JSON.stringify(res.data))
          // 2. 转换异步路由组件
          const sidebarRoutes = filterAsyncRouter(sdata)
          const rewriteRoutes = filterAsyncRouter(rdata, false, true)
          // 3. 合并动态路由与静态路由
          const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
          rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
          // 4. 动态添加路由到Vue Router实例
          router.addRoutes(asyncRoutes)
          // 5. 提交Vuex状态更新
          commit('SET_ROUTES', rewriteRoutes)
          commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))
          commit('SET_DEFAULT_ROUTES', sidebarRoutes)
          commit('SET_TOPBAR_ROUTES', sidebarRoutes)
          resolve(rewriteRoutes)
        })
      })
    }

注意:深拷贝(Deep Copy)是编程中一种对象复制方式,其核心在于创建完全独立的对象副本,包括所有层级的嵌套数据。深拷贝会递归复制原始对象的所有层级属性(包括嵌套对象、数组等),生成一个内存地址全新的对象。修改新对象不会影响原对象,两者完全隔离。

然后去到ruoyi-ui/src/api/menu.js的getRouters()方法里可以看到发送的请求路径。

根据后端返回的用户权限获取动态路由。

九、学习源码--获取动态菜单路由(后端)

查看ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java中的getRouters方法,再进入 selectMenuTreeByUserId 方法中可以看到:

    /**
     * 根据用户ID查询菜单
     * 
     * @param userId 用户名称
     * @return 菜单列表
     */
    @Override
    public List<SysMenu> selectMenuTreeByUserId(Long userId)
    {
        List<SysMenu> menus = null;
        if (SecurityUtils.isAdmin(userId))
        {
            // 管理员查询所有菜单
            menus = menuMapper.selectMenuTreeAll();
        }
        else
        {
            // 普通用户按ID查询
            menus = menuMapper.selectMenuTreeByUserId(userId);
        }
        return getChildPerms(menus, 0);
    }

    /**
     * 根据父节点的ID获取所有子节点
     * 
     * @param list 分类表
     * @param parentId 传入的父节点ID
     * @return String
     */
    public List<SysMenu> getChildPerms(List<SysMenu> list, int parentId)
    {
        List<SysMenu> returnList = new ArrayList<SysMenu>();
        for (SysMenu t : list) {
            // 根据传入的某个父节点ID,遍历该父节点的所有子节点
            if (t.getParentId() == parentId) {
                recursionFn(list, t);
                returnList.add(t);
            }
        }
        return returnList;
    }

    /**
     * 递归列表
     * 
     * @param list 分类表
     * @param t 子节点
     */
    private void recursionFn(List<SysMenu> list, SysMenu t)
    {
        // 得到子节点列表
        List<SysMenu> childList = getChildList(list, t);
        t.setChildren(childList);
        for (SysMenu tChild : childList)
        {
            if (hasChild(list, tChild))
            {
                recursionFn(list, tChild);
            }
        }
    }

    /**
     * 得到子节点列表
     */
    private List<SysMenu> getChildList(List<SysMenu> list, SysMenu t)
    {
        List<SysMenu> tlist = new ArrayList<SysMenu>();
        Iterator<SysMenu> it = list.iterator();
        while (it.hasNext())
        {
            SysMenu n = (SysMenu) it.next();
            if (n.getParentId().longValue() == t.getMenuId().longValue())
            {
                tlist.add(n);
            }
        }
        return tlist;
    }

这两段代码实现了基于用户权限的菜单树查询与树形结构转换,一步步看:

  1. 管理员路径​:调用 menuMapper.selectMenuTreeAll() 获取全量菜单(无需过滤)

  2. 普通用户路径​:调用 menuMapper.selectMenuTreeByUserId(userId),按用户角色动态过滤菜单(RBAC模型)

  3. 树形结构生成 (getChildPerms):
    输入​:遍历菜单列表,找到所有父节点ID匹配 parentId 的项(如顶层菜单)
    输出​:对每个匹配项调用 recursionFn 递归挂载子节点。

  4. 递归挂载子树(recursionFn
    getChildList():遍历全表筛选当前节点的直接子节点​(parentId = 当前节点ID
    hasChild():判断子节点是否还有下级节点(代码中未展示,通常检查子节点列表非空)

    graph TD
      A[根节点] --> B[子节点1]
      A --> C[子节点2]
      B --> D[孙节点1]
      C --> E[孙节点2]
  5. 还有优化方案(Map分组+递归​(O(n))),但是时间关系不尝试了。

十、学习源码--用户管理(前端)

来到ruoyi-ui/src/views/system/user/index.vue,可以看到用户列表方法,查看负责加载数据的 listUser 方法可以得到访问后端链接是 /system/user/list

十一、学习源码--用户管理(后端)

通过链接查找可以找到获取用户列表的方法:

    @PreAuthorize("@ss.hasPermi('system:user:list')")
    @GetMapping("/list")
    public TableDataInfo list(SysUser user)
    {
        startPage();
        List<SysUser> list = userService.selectUserList(user);
        return getDataTable(list);
    }
  1. 权限控制​:在调用 list() 方法前,通过 SpEL 表达式 @ss.hasPermi('system:user:list') 验证当前用户是否拥有 system:user:list 权限
    自定义逻辑​:@ss 指向 Spring 容器中名为 ss 的 Bean(通常为自定义权限服务类),hasPermi 是其内部定义的权限校验方法。​
    权限标识​:system:user:list 是权限标识符,对应系统中“用户列表查询”操作的权限码。

  2. startPage() 是pageHelper结合mybatis做的分页,这段代码很陌生,所以着重看一下:
    TableSupport.buildPageRequest()HttpServletRequest 中解析分页参数(如 pageNumpageSize

    public class PageUtils extends PageHelper
    {
        /**
         * 设置请求分页数据
         */
        public static void startPage()
        {
            PageDomain pageDomain = TableSupport.buildPageRequest();
            Integer pageNum = pageDomain.getPageNum();
            Integer pageSize = pageDomain.getPageSize();
            String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
            Boolean reasonable = pageDomain.getReasonable();
            PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
        }
    
        /**
         * 清理分页的线程变量
         */
        public static void clearPage()
        {
            PageHelper.clearPage();
        }
    }

    首先,TableSupport.buildPageRequest()HttpServletRequest 中解析分页参数(如 pageNumpageSize),默认值通常为 pageNum=1pageSize=10
    然后,SqlUtil.escapeOrderBySql() 对排序字段进行安全过滤,​防止 SQL 注入​(如过滤 ;-- 等危险字符)。
    接着从请求参数解析合理化开关(打开了的话reasonable就等于true),然后 PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(true)修正前端传入的不合理分页参数(如超范围的页码或无效的页大小),确保用户始终获得有效的分页数据响应。比如说: PageHelper.startPage(-5, 10).setReasonable(true); 最后实际执行的就是 pageNum=1
    最后清理分页的线程变量,PageHelper 通过 ThreadLocal<Page> 存储分页参数,clearPage() 调用 LOCAL_PAGE.remove() 移除当前线程的分页参数。这样做是因为若分页参数未被清理,可能导致后续非分页查询意外应用分页逻辑​(如 SQL 被追加 LIMIT

  3. 现在我们接着看 selectUserList 的实现:

        @Override
        @DataScope(deptAlias = "d", userAlias = "u")
        public List<SysUser> selectUserList(SysUser user)
        {
            return userMapper.selectUserList(user);
        }

    @DataScope 注解的重点在于--实现数据隔离的核心机制,是通过 ​动态 SQL 拼接 + AOP 切面​ 在运行时自动注入权限过滤条件
    此注解会被切面 ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/DataSourceAspect.java 拦截,获取当前用户和用户的全部权限,还有注解带上的别名,根据这些内容生成权限 SQL 片段,存入 user.getParams().put("dataScope", "...")然后去Mapper执行动态替换

    <select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
    		select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from sys_user u
    		left join sys_dept d on u.dept_id = d.dept_id
    		where u.del_flag = '0'
    		<!-- 此处省略一大堆if判断 -->
    		<!-- 数据范围过滤 -->
    		${params.dataScope}
    	</select>

    比如当(部门ID=100, 用户ID=123),SysUser 参数对象中会包含权限条件:user.getParams().put("dataScope", "AND (d.dept_id = 100)"); ,到了mapper这就会把${params.dataScope} 替换为AND (d.dept_id = 100)

  4. 最后是return getDataTable(list),是一个用于封装分页响应数据的工具方法,其核心作用是将MyBatis分页查询结果转换为统一的响应结构

十二、学习源码--部门树状图(前端)

ruoyi-ui/src/api/system/dept.js中可以得到查询部门列表的请求链接,想要查看树状结构是如何实现的,可以查看ruoyi-ui/src/utils/ruoyi.js

十三、学习源码--部门树状图(后端)

然后看看后端是怎么实现获取部门树列表的:

    @PreAuthorize("@ss.hasPermi('system:user:list')")
    @GetMapping("/deptTree")
    public AjaxResult deptTree(SysDept dept)
    {
        return success(deptService.selectDeptTreeList(dept));
    }

    /**
     * 查询部门树结构信息
     * 
     * @param dept 部门信息
     * @return 部门树信息集合
     */
    @Override
    public List<TreeSelect> selectDeptTreeList(SysDept dept)
    {
        List<SysDept> depts = SpringUtils.getAopProxy(this).selectDeptList(dept);
        return buildDeptTreeSelect(depts);
    }

    /**
     * 构建前端所需要下拉树结构
     * 
     * @param depts 部门列表
     * @return 下拉树结构列表
     */
    @Override
    public List<TreeSelect> buildDeptTreeSelect(List<SysDept> depts)
    {
        List<SysDept> deptTrees = buildDeptTree(depts);
        return deptTrees.stream().map(TreeSelect::new).collect(Collectors.toList());
    }

    /**
     * 构建前端所需要树结构
     * 
     * @param depts 部门列表
     * @return 树结构列表
     */
    @Override
    public List<SysDept> buildDeptTree(List<SysDept> depts)
    {
        List<SysDept> returnList = new ArrayList<SysDept>();
        List<Long> tempList = depts.stream().map(SysDept::getDeptId).collect(Collectors.toList());
        for (SysDept dept : depts)
        {
            // 如果是顶级节点, 遍历该父节点的所有子节点
            if (!tempList.contains(dept.getParentId()))
            {
                recursionFn(depts, dept);
                returnList.add(dept);
            }
        }
        if (returnList.isEmpty())
        {
            returnList = depts;
        }
        return returnList;
    }
  1. 先从selectDeptTreeList开始,使用SpringUtils.getAopProxy(this)获取代理对象,确保AOP生效(如事务、权限等),调用selectDeptList获取原始部门列表,再将结果转换为树形选择器结构

  2. 然后是buildDeptTreeSelect将部门树转换为树形选择器格式,调用buildDeptTree构建部门树,使用Java Stream将SysDept转换为TreeSelectTreeSelect是前端树形选择器所需的数据结构)。

  3. 接下来说明核心方法buildDeptTree当前部门的父ID不在部门ID集合中时(if (!tempList.contains(dept.getParentId()))),是顶级节点或者孤儿节点,直接进入递归并构建当前部门的子树。当没有找到任何根节点时,所有节点都有父节点(非树结构)或者循环引用导致找不到根节点,直接返回原始平面列表。