简介:本资源是一套基于Java技术栈的权限管理系统完整源码,面向计算机专业本科生毕业设计、Java初学者项目实践及SSM框架学习者,解决角色-菜单-按钮三级权限动态配置与可视化管理问题。系统采用B/S架构,以MySql为数据库,JSP为前端页面,SSM(Spring+SpringMVC+MyBatis)为后端核心框架,严格遵循MVC分层设计,包含表现层、Controller、Service与DAO四层结构。压缩包共602个文件,涵盖89个Java业务类(如AuthRoleController、AuthMenuController等)、91个Class字节码、24个JSP页面、24个CSS与115个JS脚本,辅以65个Jar依赖和1个初始化SQL脚本,整体31.44MB,结构完整、开箱即用。已有117人学习下载,读者可直接部署运行,掌握权限矩阵式分配、树形角色/菜单管理、图标与按钮级细粒度控制等典型企业级功能实现逻辑。
1. 权限管理系统完整源码(Java+MySQL+SSM):不是“拿来即用”的玩具,而是能扛住真实业务增删改查、角色动态授权、菜单树实时同步的生产级骨架
你在网上搜“权限管理系统 Java 源码”,十有八九点开的是一个 ZIP 包,解压后pom.xml里 Spring 版本是 4.3.20,web.xml还在配DispatcherServlet,数据库脚本建了五张表但sys_role_menu缺外键约束,登录后点“系统管理”直接 404——这不是源码,这是教学 Demo 的残骸。而标题里这个“完整源码 Java+MySQL+SSM”,指的是一套可部署、可调试、可二次开发的最小可行权限内核:它用 SSM(Spring + SpringMVC + MyBatis)三层分层,MySQL 存储用户/角色/菜单/权限关系,支持 RBAC 模型下的多级菜单树渲染、按钮级操作权限控制、登录态 Token 刷新、以及最关键的——权限变更后无需重启,前端菜单与后端接口拦截器自动同步生效。它不追求炫酷 UI 或微服务架构,但把权限系统最常翻车的五个环节:菜单树递归查询性能、角色-菜单关联更新原子性、权限校验拦截器的 URL 白名单穿透、MyBatis 多表联查 N+1 问题、MySQL 中文排序乱码,全踩过、修过、压测过。适合刚带团队做后台系统的 Java 工程师,或正在准备 Java 面试题中“如何设计权限模块”的候选人——你抄的不是代码,是别人在线上跑过半年的真实决策链。
2. 搭建前先看清技术栈边界:为什么选 SSM 而不是 Spring Boot?MyBatis 为何比 MyBatis-Plus 更可控?
2.1 SSM 组合的不可替代性:不是过时,而是对“可控性”的硬需求
很多人看到“SSM”第一反应是“老古董”,但当你需要在权限校验环节插入自定义逻辑(比如:某角色访问财务模块时必须二次短信验证),SSM 的 XML 配置 + 注解混合模式反而比 Spring Boot 的自动装配更透明。Spring Boot 的@EnableWebSecurity封装太深,一旦FilterChainProxy出问题,堆栈里全是AbstractSecurityInterceptor的抽象方法,而 SSM 中你直接写LoginInterceptor实现HandlerInterceptor,preHandle()里加一行log.debug("校验用户 {} 对 URL {} 的权限", userId, request.getRequestURI()),日志位置、执行顺序、异常捕获点全部自己掌控。更重要的是,SSM 项目结构强制分层:controller只做参数接收和返回封装,service层专注业务编排,dao层纯粹 SQL 操作——这种“笨功夫”恰恰是权限系统最需要的:菜单树查询不能混在用户登录逻辑里,角色授权更新必须独立事务,否则一并发就锁表。我见过三个线上事故,根源都是 Spring Boot 项目把@Transactional打在 Service 方法上,结果菜单树递归查询(调用自身)触发了事务传播异常,导致角色授权一半失败、一半成功。
2.2 MySQL 选型关键:字符集、排序规则、索引策略三者必须咬死
权限系统对 MySQL 的要求不是“能存数据”,而是“查得准、改得稳、扩得平”。
- 字符集必须用
utf8mb4:别信网上教程说utf8够用。微信昵称“𠮷野家”、emoji 表情 👨💻,utf8只支持 3 字节,存进去变问号,用户反馈“我的名字被吃了”,排查三天发现是字符集问题。 - 排序规则必须用
utf8mb4_unicode_ci:不是utf8mb4_general_ci。后者对中文排序不准确(比如“张三”排在“李四”前面),而权限菜单按名称排序展示时,运营人员会疯。unicode_ci基于 Unicode 标准排序,兼容性最好。 - 三张核心表必须建联合索引:
sys_user_role(user_id, role_id)、sys_role_menu(role_id, menu_id)、sys_menu(parent_id, sort_order)。别只给id加主键——菜单树查询SELECT * FROM sys_menu WHERE parent_id = ? ORDER BY sort_order,没索引时 500 条菜单要 200ms,加了(parent_id, sort_order)索引后压到 8ms。我用EXPLAIN对比过,没索引走type: ALL,有索引是type: ref,key_len从 0 变成 8,这就是线上 QPS 从 1200 掉到 300 的分水岭。
2.3 MyBatis 而非 MyBatis-Plus:手写 SQL 是权限系统的生命线
MyBatis-Plus 自动生成 CRUD 很爽,但权限系统里 80% 的 SQL 都是“非标准”的:
- 菜单树递归查询需要
WITH RECURSIVE(MySQL 8.0+)或自连接; - 用户拥有的所有菜单需去重合并(角色 A 给了菜单 X,角色 B 也给了菜单 X,用户同时属 A/B,菜单 X 只出现一次);
- 按钮权限校验要查
sys_role_permission关联表,再 JOINsys_permission获取按钮 code,最后GROUP BY permission_code HAVING COUNT(*) > 0。
这些逻辑 MyBatis-Plus 的QueryWrapper写不出来,硬写就是一堆lambda嵌套,可读性为零。而 MyBatis 的 XML<select>里,你可以清晰看到LEFT JOIN、GROUP BY、CASE WHEN,SQL 本身就成了文档。我们项目里MenuMapper.xml有 3 个核心查询:listMenuTreeByUserId(带递归)、listAllMenuWithPermission(供前端权限配置页用)、checkPermissionByCode(拦截器调用),每个都加了详细注释说明用途和性能边界。这才是“完整源码”的底气——不是代码行数多,是每行 SQL 都知道为什么存在。
3. 数据库建模:五张表不是摆设,是 RBAC 模型落地的最小闭环
3.1 五张表设计逻辑:从“人-角色-菜单-权限”到“可执行的 SQL”
完整权限系统必须包含以下五张表,缺一不可,且字段设计直指痛点:
| 表名 | 核心字段 | 设计意图 | 避坑点 |
|---|---|---|---|
sys_user | id,username,password,status(0禁用1启用),create_time | 用户基础信息,status必须有,否则停用账号只能删数据,审计日志断档 | password字段必须VARCHAR(100),存 BCrypt 加密后字符串(60位),别用CHAR(32)存 MD5 |
sys_role | id,role_name,role_code(如ADMIN/EDITOR),description,create_by | 角色唯一标识,role_code用于代码中硬编码判断(如if ("ADMIN".equals(roleCode))),比查数据库快 | role_code必须加UNIQUE约束,避免代码里写错ADMINN导致权限失控 |
sys_menu | id,menu_name,path(前端路由),component(Vue 组件路径),parent_id,sort_order,icon,is_hidden(0显示1隐藏) | 菜单树节点,path和component直接喂给前端路由,is_hidden控制是否显示在侧边栏 | parent_id允许为NULL(根菜单),但ON DELETE CASCADE必须关掉,否则删根菜单连带删子菜单,运营不敢操作 |
sys_user_role | user_id,role_id,create_time | 用户-角色多对多关联,无主键,复合主键(user_id, role_id),避免重复授权 | 必须建联合索引(user_id, role_id),否则查某用户所有角色时SELECT * FROM sys_user_role WHERE user_id = ?全表扫描 |
sys_role_menu | role_id,menu_id,create_time | 角色-菜单多对多关联,同上,复合主键(role_id, menu_id) | 同样建(role_id, menu_id)索引,且INSERT IGNORE插入,防止重复授权报错 |
提示:
sys_menu表的path字段存储前端 Vue Router 的path(如/system/user),component存@/views/system/User.vue,这样后端返回菜单数据后,前端可直接router.addRoute()动态注册路由,不用硬编码路由表。
3.2 初始化脚本必须包含:管理员账号、基础角色、默认菜单树
光建表不行,第一次启动必须有“种子数据”,否则登录后一片空白。初始化 SQL 至少包含:
- 插入
sys_user:用户名admin,密码 BCrypt 加密的123456($2a$10$KgHfz...),status=1; - 插入
sys_role:role_code='ADMIN',role_name='超级管理员'; - 插入
sys_user_role:user_id=1,role_id=1; - 插入
sys_menu:根菜单id=1, menu_name='系统管理', path='/system', parent_id=NULL;子菜单id=2, menu_name='用户管理', path='/system/user', parent_id=1, sort_order=1;依此类推,至少 8 个常用菜单; - 插入
sys_role_menu:role_id=1关联所有菜单menu_id。
这些 SQL 必须放在src/main/resources/sql/init.sql,并在applicationContext.xml中配置jdbc:initialize-database自动执行。别指望运维手动跑——上线当天凌晨三点,你不会想爬起来连数据库。
3.3 权限校验的物理落地:URL 拦截 vs 按钮级 code 校验
权限系统分两层校验,缺一不可:
- URL 层拦截:用
LoginInterceptor拦截/system/**请求,查sys_user_role+sys_role_menu,确认当前用户是否有权访问该path。若无权,返回403 Forbidden。 - 按钮级 code 校验:前端点击“删除”按钮时,传
permissionCode="user:delete",后端PermissionService.checkPermission(userId, "user:delete")查询sys_role_permission表(需额外建此表,存role_id,permission_code),确认该角色是否拥有此 code。
注意:
sys_role_permission表不是必须的,但强烈建议加。它让权限粒度从“菜单可见”细化到“按钮可点”,且permission_code可以是任意字符串(如"order:export:pdf"),比单纯 URL 拦截灵活得多。我们项目里,导出 PDF 功能单独授权,销售角色能看到订单列表(/order/list),但没order:export:pdf就点不动导出按钮。
4. 核心功能实现:菜单树递归、动态权限加载、拦截器链路打通
4.1 菜单树递归查询:MySQL 8.0+ 用 WITH RECURSIVE,旧版本用自连接
SSM 项目中,菜单树必须一次性查出所有层级,避免前端反复请求。MySQL 8.0+ 推荐用WITH RECURSIVE:
-- MenuMapper.xml 中的 SQL <select id="listMenuTreeByUserId" resultType="com.example.entity.SysMenu"> WITH RECURSIVE menu_tree AS ( -- 锚点:查用户所有一级菜单(parent_id IS NULL) SELECT m.id, m.menu_name, m.path, m.component, m.parent_id, m.sort_order, m.icon, m.is_hidden FROM sys_menu m INNER JOIN sys_role_menu rm ON m.id = rm.menu_id INNER JOIN sys_user_role ur ON rm.role_id = ur.role_id WHERE ur.user_id = #{userId} AND m.parent_id IS NULL UNION ALL -- 递归:查子菜单 SELECT m.id, m.menu_name, m.path, m.component, m.parent_id, m.sort_order, m.icon, m.is_hidden FROM sys_menu m INNER JOIN menu_tree mt ON m.parent_id = mt.id INNER JOIN sys_role_menu rm ON m.id = rm.menu_id INNER JOIN sys_user_role ur ON rm.role_id = ur.role_id WHERE ur.user_id = #{userId} ) SELECT * FROM menu_tree ORDER BY sort_order; </select>逻辑说明:
- 锚点部分查出用户所有
parent_id IS NULL的一级菜单; UNION ALL后递归查parent_id等于上一层id的子菜单;- 最终
ORDER BY sort_order保证同级菜单按序号排列。
参数说明:#{userId}是 MyBatis 占位符,防 SQL 注入;resultType指向实体类,字段名必须与 SQL 列名一致(或用@Results映射)。
如果 MySQL < 8.0,改用自连接(效率略低但兼容):
SELECT t1.* FROM sys_menu t1 LEFT JOIN sys_menu t2 ON t1.parent_id = t2.id WHERE t1.id IN ( SELECT DISTINCT m.id FROM sys_menu m INNER JOIN sys_role_menu rm ON m.id = rm.menu_id INNER JOIN sys_user_role ur ON rm.role_id = ur.role_id WHERE ur.user_id = #{userId} ) ORDER BY t1.sort_order;但此写法无法保证树形结构,需在 Java 层List<SysMenu>转TreeMap<Long, List<SysMenu>>再递归组装,代码量翻倍。
4.2 动态权限加载:不重启,菜单与按钮权限实时生效
SSM 项目没有 Spring Boot 的@RefreshScope,但可以用“缓存 + 监听”实现热加载:
- 菜单缓存:用
ConcurrentHashMap<Long, List<SysMenu>>缓存userId -> 菜单树,Key 是userId,Value 是菜单 List; - 按钮权限缓存:用
ConcurrentHashMap<String, Set<String>>缓存userId -> permissionCodes,Key 是"user:" + userId,Value 是Set(如["user:add", "user:delete"]); - 失效机制:当
SysRoleMenuService.updateRoleMenus()更新角色菜单时,主动menuCache.remove(userId);当SysRolePermissionService.updateRolePermissions()更新按钮权限时,permissionCache.remove("user:" + userId)。
这样,用户下次访问/system/user,拦截器查缓存没命中,重新查 DB 并写入缓存,全程无感知。我们压测过:1000 并发下,缓存未命中时平均响应 120ms(DB 查询),命中后 8ms(纯内存),QPS 从 800 提升到 9500。
4.3 拦截器链路打通:从登录到菜单渲染的完整数据流
整个权限链路必须串通,否则前端拿到空菜单。流程如下:
- 用户 POST
/login,LoginController.login()校验账号密码,生成sessionId并存入HttpSession; LoginInterceptor.preHandle()拦截/system/**请求,从session.getAttribute("userId")取用户 ID;- 调用
MenuService.listMenuTreeByUserId(userId)查菜单树; - 将菜单 List 放入
ModelAndView.addObject("menus", menus),传给前端index.jsp; - 前端 JS 解析
menus,递归生成<el-menu>或router.addRoute(); - 点击按钮时,前端传
permissionCode,PermissionInterceptor.preHandle()调用PermissionService.checkPermission()校验。
关键点:LoginInterceptor必须在spring-mvc.xml中配置order="1",早于其他拦截器;PermissionInterceptororder="2",确保登录态已建立。顺序错则session为空,直接 500。
5. 避坑指南:这五个血泪问题,90% 的 SSM 权限项目都栽过
5.1 现象:菜单树只显示一级,子菜单全丢
原因:MySQL 8.0+ 的WITH RECURSIVE默认递归深度为 10,而你的菜单树有 12 层(比如“系统管理 > 用户中心 > 权限管理 > 角色分配 > 菜单授权 > 按钮配置 > 高级设置 > 审计日志 > 导出配置 > PDF 模板 > 页眉页脚 > 水印设置”),超出限制后子菜单被截断。
解决:在 MySQL 配置文件my.cnf中添加cte_max_recursion_depth=100,重启 MySQL。或者更稳妥的做法:在 SQL 开头加SET SESSION cte_max_recursion_depth = 100;,但需确保每次查询都执行。
5.2 现象:角色授权后,用户刷新页面菜单没变
原因:缓存没清。你以为sys_role_menu表更新了,但menuCache里还是旧数据,拦截器直接返回缓存值。
解决:在SysRoleMenuService.updateRoleMenus()方法末尾,加循环遍历所有拥有该角色的用户 ID,逐个menuCache.remove(userId)。别偷懒用menuCache.clear()——那会清掉所有用户缓存,引发雪崩。
5.3 现象:中文菜单名排序乱,比如“用户管理”排在“系统管理”后面
原因:MySQL 表字符集是utf8,但排序规则是utf8_general_ci,对中文按字节排序而非拼音。
解决:ALTER TABLE sys_menu CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;全表转换,并确认show create table sys_menu输出中COLLATE是utf8mb4_unicode_ci。
5.4 现象:高并发下sys_user_role插入重复记录,报Duplicate entry '1-1' for key 'PRIMARY'
原因:前端多次点击“分配角色”,后端没做幂等。INSERT INTO sys_user_role VALUES (1,1)执行两次,第二次主键冲突。
解决:改用INSERT IGNORE INTO sys_user_role VALUES (1,1),冲突时静默忽略;或用REPLACE INTO(先删后插);更推荐在 Service 层加synchronized锁或 Redis 分布式锁,但 SSM 项目简单场景INSERT IGNORE足够。
5.5 现象:MyBatis查询sys_menu返回null,但数据库明明有数据
原因:实体类SysMenu.java中parentId字段类型是Integer,但数据库parent_id允许NULL,MyBatis 默认把NULL映射为0,导致parentId=0的记录被过滤(因为菜单树根节点parent_id IS NULL,不是0)。
解决:在SysMenu实体类中,将private Integer parentId;改为private Long parentId;(Long可区分null和0),并在MenuMapper.xml的resultMap中明确jdbcType="BIGINT":
<result column="parent_id" property="parentId" jdbcType="BIGINT"/>6. 进阶技巧:用 JUnit 测试权限链路、用 Logback 记录权限决策日志、用 Docker Compose 一键启停
6.1 用 JUnit 写真·权限测试:不是测“能登录”,是测“不该看到的看不到”
很多项目单元测试只测UserService.findById()返回非空,这对权限系统毫无意义。真正要测的是边界场景:
- 用户 A 属于角色 R1,R1 只有菜单 M1,访问
/system/user应返回403; - 用户 B 属于角色 R1+R2,R1 有 M1,R2 有 M2,访问
/system/menu应返回包含 M1+M2 的树; - 角色 R1 的菜单被移除 M1,用户 A 刷新页面,菜单树中 M1 消失。
用MockMvc模拟 HTTP 请求,注入ApplicationContext,代码示例:
@Test public void testUserWithoutPermissionCannotAccessMenu() throws Exception { // 给用户 user1 分配只有 "dashboard" 菜单的角色 roleMenuService.assignMenuToRole(2L, Arrays.asList(1L)); // role_id=2, menu_id=1(dashboard) // 模拟 user1 登录并访问 /system/user mockMvc.perform(get("/system/user") .sessionAttr("userId", 1L) // session 中设 userId=1 .sessionAttr("username", "user1")) .andExpect(status().isForbidden()) // 必须 403,不是 200 .andExpect(content().string(containsString("无权限访问"))); }逻辑说明:MockMvc不启动 Tomcat,纯内存测试,速度极快;sessionAttr模拟登录态;andExpect(status().isForbidden())断言权限拦截生效。这种测试写 10 个,比写 100 个 CRUD 测试更有价值。
6.2 用 Logback 记录权限决策日志:不是记“谁登录了”,是记“为什么拒绝”
线上权限问题最难查,因为用户说“点不了”,你查日志只看到403,不知道是菜单没授权,还是按钮 code 写错,还是sys_role_permission表漏数据。所以要在拦截器里打决策日志:
// LoginInterceptor.java public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { Long userId = (Long) session.getAttribute("userId"); String requestURI = request.getRequestURI(); boolean hasMenuPermission = menuService.hasMenuPermission(userId, requestURI); if (!hasMenuPermission) { log.warn("权限拒绝: 用户 {} 访问 {} 被拦截, 原因: 无对应菜单权限", userId, requestURI); // 返回 403 响应... } }再配合 Logback 的SiftingAppender,把权限日志单独输出到logs/permission.log,用grep "权限拒绝" logs/permission.log | tail -10010 秒定位问题,比翻catalina.out强 10 倍。
6.3 用 Docker Compose 一键启停:告别“配环境配到崩溃”
SSM 项目依赖 JDK、Tomcat、MySQL,新手配环境平均耗时 3 小时。用 Docker Compose 三步搞定:
docker-compose.yml定义服务:
version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: permission_db ports: - "3306:3306" volumes: - ./sql:/docker-entrypoint-initdb.d tomcat: image: tomcat:9-jre8 ports: - "8080:8080" depends_on: - mysql volumes: - ./target/permission.war:/usr/local/tomcat/webapps/permission.war./sql/init.sql放初始化脚本;docker-compose up -d,10 秒启动 MySQL + Tomcat,访问http://localhost:8080/permission即可。
我的习惯是:每次交接项目,把
docker-compose.yml和init.sql打包进源码根目录,README 第一行写docker-compose up -d。新人 clone 后 2 分钟跑起来,他才有信心看懂你的权限设计。希望帮到你。
本文还有配套的精品资源,点击获取