
1. 系统管理模块在后端项目里的真实定位做了这么多年后端我越来越确认一件事系统管理模块才是检验后端工程师基本功的试金石。你们在很多前后端分离项目里看到的用户管理、角色管理、菜单管理、部门管理、字典管理、操作日志和登录日志表面看就是一组普通的增删改查接口实际它们是整个系统的权限中枢和审计底座。这个模块不炸业务模块怎么做都还有补救空间这个模块一旦权限失控后面接手的同事大概率只能推倒重来。这里先把概念收拢一下。系统管理模块通常服务于两类人一类是系统管理员负责维护组织架构、账号、角色、菜单、字典和参数另一类是普通用户他们不直接感知这个模块但每次登录、每次点击菜单、每次调用接口都在跟它打交道。前端需要从后端拿我有哪些菜单、我有哪些按钮权限后端需要在这条链路的每一步决定这个请求放不放行。所以它不是一个普通 CRUD 模块而是连接登录体系、路由体系和接口安全体系的枢纽。先说一个最常见的认知误区很多人把系统管理模块当成先写一批接口交差的脚手架代码等业务模块做起来之后权限需求一变才发现表结构根本撑不住。比如想在用户上挂多个部门想在角色里区分数据范围想对某个按钮做临时授权结果发现自己设计的用户表只有单个 role_id角色表没有数据权限范围字段菜单表里目录和按钮混在一张表却没有类型字段。这些问题不是功能开发问题是模型设计问题模型设计的坑到后期基本无解。1.1 为什么每个业务系统最后都会长出一个系统管理模块不需要什么高深理由只要有登录和分工就必然有用户、角色和菜单的需求。我见过最小的管理后台只有一个管理员账号直接在配置里写死但后面业务发展起来运营要一个账号、客服要一个账号、财务要一个账号还要限制各自的菜单和按钮就只能回来补系统管理模块。还有一个被忽略的理由是审计。线上出问题的时候你总得知道是哪个用户在什么时间干了什么。所以操作日志和登录日志不是可选项。尤其是涉及订单、支付、审批这类敏感操作没有日志就等于把脑袋伸出去让别人砍。从前后端分离的视角再看一层前端路由和菜单不是写死在代码里的而是登录后根据用户的角色动态生成。这就要求后端不仅返回 token还要返回用户信息、角色集合和权限标识集合。前端根据这些数据去渲染侧边栏拦截路由跳转控制按钮显示。换句话说系统管理模块输出的是权限视图整个前端的 UI 骨架都依赖它。1.2 模块边界怎么划才不会把业务代码拖下水我的做法是给系统管理模块定三条硬边界。第一它只放平台级通用能力不掺业务字段。用户表可以存归属部门但绝对不要把会员等级客户来源这种业务字段堆进来业务信息应该放业务表里通过 userId 关联。第二所有系统管理接口必须能设置权限标识统一按 system:xxx:yyy 的格式命名比如 system:user:list、system:role:edit、system:menu:delete。第三系统管理模块的代码在物理上独立成包接口路径统一以 /system 开头方便网关做路由隔离和统一日志。边界划清楚之后业务模块做起来会非常舒服。业务表只关心自己的业务数据需要知道当前用户是谁就去拿 SecurityContext 或者公共上下文里的 LoginUser需要判断有没有某个权限直接用 PreAuthorize 注解不用自己写第二套鉴权逻辑。我自己见过最痛苦的项目是每个 Controller 里都有一段复制粘贴的判断用户角色代码业务一复杂那段逻辑改了五处只改了三处线上数据就是这么漏出去的。2. 先把RBAC这张底网织好表结构设计经验先讲清楚 RBAC 的核心用户与权限不直接挂钩用户先挂到角色上角色再拥有权限集合。为什么中间要多一层角色因为直接给用户绑权限几百个用户时你还能忍几千个用户时就完全失控了。加一层角色新增一个人只需要给他分配角色调整权限只需要改角色用户侧无感生效。但要注意RBAC 落地的时候有两个方向一种是用户-角色-菜单/接口的粗粒度权限解决能进哪个界面、能点哪个按钮另一种是数据权限解决能看到哪些数据比如销售只能看自己的订单部门主管能看本部门的订单。这两种东西必须分开设计。表结构上前者用菜单权限表后者通常用角色表上的 data_scope 字段再加自定义规则。2.1 五张核心表的字段与关联我用得最多的是下面这套表组合它覆盖了绝大多数管理后台的需求表名作用关键字段sys_user系统用户user_id, dept_id, username, password, status, del_flagsys_role角色role_id, role_name, role_key, data_scope, statussys_menu菜单/按钮权限menu_id, parent_id, menu_type, perms, path, componentsys_user_role用户-角色关联user_id, role_idsys_role_menu角色-菜单关联role_id, menu_idsys_user 最容易被忽略的是 dept_id。这个字段不只是一个组织归属的展示字段它是后面做数据权限过滤的锚点。比如销售主管希望看到本部门及以下部门的数据程序在查询业务表时就可以通过 dept_id 把数据范围限定住。status 和 del_flag 一定要有前者控制账号是否禁用后者做逻辑删除。密码字段只存 BCrypt 加密后的哈希串。sys_role 里除了 role_name最好加一个 role_key 作为代码层面的唯一标识比如 admin、common。为什么不用 role_id因为数据库主键在迁移和合并环境时可能变化而 role_key 是业务常量可以在代码里安全判断。data_scope 字段表示数据权限范围常见值有全部、本部门及以下、本部门、仅本人、自定义。自定义一般还要配一张 sys_role_dept 表来指定可见部门这个看项目规模决定要不要加。sys_menu 里的 menu_type 我习惯用 M(目录)、C(菜单)、F(按钮) 三种。目录是顶级分组菜单是左侧导航的叶子节点按钮是页面里的操作权限。perms 字段对目录和菜单不一定必须但按钮权限一定要写比如 system:user:add。前端拿到这些 perms 集合后用指令判断按钮要不要渲染后端用同样的字符串做接口鉴权。sys_user_role 和 sys_role_menu 就是两张纯关联表各带主键或联合主键。不要嫌多表查询麻烦权限体系一旦出现一个用户多个角色、一个角色多个菜单的情况关联表是最容易扩展和维护的。2.2 部门、字典、日志这类辅助表的设计细节部门表 sys_dept 是树形结构parent_id 指向上级部门根节点可以设 parent_id 0。我有一个强烈建议一定要加 ancestors 字段例如当前部门 id12上级是 3那 ancestors 就存 0,3。这个字段用来查询本部门及以下所有部门时非常方便直接构造 dept_id in (子部门列表)不用递归。字典表要分成 sys_dict_type 和 sys_dict_data 两张前者定义字典类型比如 order_status后者存具体字典项比如 status0 表示待支付、status1 表示已支付。把业务里的枚举值抽成字典好处是前端下拉框直接从后端拿运营可以自己维护不用每次加枚举都发版本。代价是查询多一层缓存这个可以通过本地缓存或者 Redis 解决。日志表至少两张sys_oper_log 记录操作日志sys_login_log 记录登录日志。操作日志字段包括操作人、操作模块、请求方法、请求路径、请求参数、返回结果、耗时、IP、操作时间。注意不要把请求体原样存巨大字段遇到文件上传一定要截断。登录日志至少要有用户名、登录状态、IP、浏览器 User-Agent、登录时间。日志表的写入场景是高并发、低价值所以不要和业务接口放在同一个事务里要么单独线程池要么直接异步落库。3. 认证与鉴权链路JWT Spring Security 的串法表结构定了之后真正难的部分在认证鉴权。这里我用 Java 技术栈的 Spring Boot 3 Spring Security JWT Redis 来拆解这套组合在目前前后端分离项目里非常常见。为什么不自己在拦截器里手动解析 token因为认证流程的边界情况很多token 过期、刷新、用户被禁用、权限变更、并发登录、CSRF、跨域预检Spring Security 的过滤器链把这些能力标准化了你只需要按自己的业务去填充。3.1 登录接口里到底要做几件事很多人写登录接口只做了三件事查用户、比密码、发 token。但实际生产环境里登录接口至少要按这个顺序做完整校验验证码。验证码存在 Rediskey 用 uuid创建时设置过期时间校验后立刻删除防止暴力重放。根据用户名查询用户。这里要注意查询时把密码字段带出来因为后面要比较哈希值但返回给前端时永远不要序列化密码字段。检查用户状态和角色状态。status 为 1 的账号直接拒绝登录并记录登录日志。用 BCryptPasswordEncoder 的 matches 方法校验密码。不要用 MD5不要自己发明加盐逻辑。登录成功后生成 JWT。JWT 里只放 userId 和一个 tokenId不要塞用户角色和权限列表因为 JWT 是签名但未加密的而且权限数据放在 token 里无法实时更新。把 LoginUser 对象包含用户基本信息、角色集合、权限标识集合存入 Rediskey 可以用 login_token:userId:tokenId指定过期时间。返回结果里携带 token 和用户信息。前端把 token 存起来每次请求自动放到 Authorization 头。登录失败也需要写 log 吗需要。登录失败日志对安全审计特别重要连续失败次数还可以作为账号锁定的判断依据。我一般会用 Redis 记录失败次数比如 1 小时内失败 5 次锁定 15 分钟。3.2 接口级鉴权为什么必须靠权限标识前后端分离项目里最大的安全误区是以为前端隐藏了菜单和按钮用户就看不到那些功能了。实际上接口才是数据的真正入口任何人只要拿到一个 token就可以绕过前端直接请求接口。所以每个敏感接口都必须由后端鉴权。Spring Security 里我习惯配合自定义注解。先定义一个 PermissionService从 SecurityContext 中取当前登录用户的权限集合判断是否包含某个权限标识Service(ss) public class PermissionService { public boolean hasPermi(String permission) { if (StringUtils.isEmpty(permission)) { return false; } LoginUser loginUser SecurityUtils.getLoginUser(); if (loginUser null) { return false; } // 超级管理员直接放行 if (loginUser.isAdmin()) { return true; } return loginUser.getPermissions().contains(permission); } }Controller 里这样用PreAuthorize(ss.hasPermi(system:user:list)) GetMapping(/list) public TableDataInfo list(SysUser user) { ... }这样配置的好处是权限标识和表里的 sys_menu.perms 字段完全对得上。菜单管理界面上每加一个按钮权限标识后端接口只要用同一串字符串做注解前端按钮也用同一串字符串做 v-hasPermi 判断三个地方一套数据不会出现前端按钮看不到但接口能调的错位。3.3 Redis 在认证链路中的角色Redis 在体系里做了三件事。第一存验证码和登录失败次数第二存用户登录态实现真正可注销、可踢人、可续期的会话第三缓存用户的权限集合。为什么要存权限而不是每次鉴权都查数据库查一次权限集合要关联用户表、角色表、菜单表一个请求里可能有好几个接口要做 PreAuthorize 判断次次查数据库性能顶不住。重点是权限变更后的缓存同步。系统管理员改了某个角色的菜单如果缓存里的旧权限不清理用户在有效期内依然能调用已经收回的接口这是权限系统的硬伤。我的做法是更新角色菜单的时候删除该角色关联的所有用户的 LoginUser 缓存更新用户角色的分配时删除该用户的缓存。用户下一个请求进来解析 token 时发现缓存不存在就重新从数据库加载权限并写入 Redis。这一步的核心代码如下// 角色菜单变更后 userOnlineService.removeUserCacheByRoleId(roleId); // 用户角色重新分配后 userOnlineService.removeUserCacheByUserId(userId);如果项目里已经用上了消息队列也可以用事件发布通知所有实例清缓存没有消息队列就靠 Redis key 删除后自动重新加载来兜底。这里要特别注意分布式环境下的延迟问题权限变更后未必立刻在所有实例生效但通常一两秒内能收敛。4. 用户、角色、菜单接口的分层落地Controller-Service-Mapper 实际写法系统管理模块的接口特别适合展示一套规整的三层结构因为逻辑不复杂但边界必须清晰。我自己总结的规则是Controller 只做参数接收和结果封装Service 做业务规则和事务控制Mapper 只做 SQL 查询。事务、异常、唯一性校验这类问题不在 Controller 里写。4.1 用户管理分页、新增、分配角色、重置密码用户管理的核心接口就六个分页查询、根据用户编号查询详情、新增用户、修改用户、删除用户、重置密码。分页查询一般配合 PageHelperGetMapping(/list) public TableDataInfo list(SysUser user) { startPage(); ListSysUser list userService.selectUserList(user); return getDataTable(list); }startPage 是 PageHelper 的静态方法它通过拦截器把下一条 SQL 包成分页查询返回的 list 实际是 Page 对象再由 getDataTable 把 total 和 rows 封装成前端需要的结构。这里有一个坑startPage 和它作用的那条 SQL 之间不能夹着其他 SQL 操作一旦中间有别的查询PageHelper 会把分页参数作用到错误的 SQL 上。新增用户时最重要的一步是唯一性校验。username 必须唯一但如果你做了逻辑删除就有一个经典坑删除的用户还占着 username再新增同名用户时唯一索引直接报错。解决思路我放到第 5 章展开。新增用户还需要给一个初始密码通常用一个默认值 123456并且把 isNeedUpdatePwd 这类字段标记为 true前端检测到该字段就弹窗要求改密。分配角色是用户管理里另一个容易做错的地方。前端提交的 userIds 和 roleIds 是一对多关系Service 里必须在事务内先删除 sys_user_role 里该用户的全部记录再批量插入新的关联记录。不要只做增删差量虽然效率高但业务场景下全删全插最可靠而且这个表数据量一般不大没必要做复杂 diff。4.2 角色管理分配菜单与同步更新角色管理的重点是角色-菜单关系。新增角色时前端会传来一个菜单 id 的树形勾选列表注意这个列表里一般既包含父级目录也包含子菜单和按钮不要只存叶子节点。为什么因为前端动态路由要判断当前角色有没有某个目录或菜单的可见权如果目录没被勾选子菜单即使有权限也无法在侧边栏展示。所以插入 sys_role_menu 的时候全部按提交的 menuIds 插入即可。修改角色时则要先更新 sys_role 基础信息再删除原有的角色菜单关联再重新插入新的关联。这两个操作必须放在同一个事务里否则中途异常会出现角色信息是新的、菜单权限是旧的这种脏数据。删除角色前必须检查 sys_user_role 里是否还有用户引用。如果有前端要给出明确提示该角色已分配给 N 个用户请先解除分配后再删除。否则直接删除角色会导致这些用户的权限集合变成幽灵数据登录后菜单无法正常加载。多表操作建议写成下面这种事务控制方式Transactional(rollbackFor Exception.class) public void updateRole(SysRole role) { // 1. 更新角色表 roleMapper.updateRole(role); // 2. 删除旧的菜单关联 roleMenuMapper.deleteRoleMenuByRoleId(role.getRoleId()); // 3. 插入新的菜单关联 insertRoleMenu(role); }4.3 菜单管理树形结构、动态路由与按钮权限菜单管理的查询接口返回的不是平铺列表而是树形结构。前端拿到树之后做两件事一是管理界面的树形表格二是登录后根据角色可访问菜单构建动态路由。后端这边的核心是递归构建树public ListSysMenu buildMenuTree(ListSysMenu menus) { // 先按 parentId 分组再从根节点开始组装 children }递归本身不难难点在数据校验。比如 parentId 不能指向自身不能形成环否则前端渲染路由时会死循环。我见过一个项目在菜单表里把 A 菜单的 parentId 配成了 BB 的 parentId 又配成了 A前端页面直接卡死。所以新增菜单时建议做一次父节点链检测确保新菜单的父节点不能是自己的子节点。按钮权限这块要跟菜单类型联动。如果 menu_typeF那 component 和 path 都可以不填只填 perms 和菜单名称如果 menu_typeC则必须填 component对应前端页面的组件路径。后端接口在返回路由给前端时通常会把按钮类型的菜单过滤掉因为它们不参与路由只参与权限标识集。5. 上线前最容易翻车的细节跨域、逻辑删除、权限缓存一致性5.1 三个真实踩过坑唯一索引、树形递归、跨域第一个坑是逻辑删除和唯一索引打架。MySQL 的表结构里 username 上建了唯一索引用户删除时我们把 del_flag 从 0 改成 1数据还在索引还占着导致新用户无法使用同一个用户名。常规解法有几种删除时把 username 改名比如 username_del_{id}或者索引字段改成 (username, del_flag)但逻辑删除的字段是 0 和 1删除多条同样 username 的记录会重复冲突比较稳的方案是数据库表去掉唯一索引把唯一性校验完全放在 Service 层配合分布式锁避免并发创建同名用户。第二个坑是树形递归的效率和深度问题。部门表、菜单表的深度通常不会太大但如果不加控制递归查询会变成多次全表查询。更常见的是删除父节点时没有校验子节点导致留下一堆孤儿节点。所以我在删除接口里都会先查子节点数量大于 0 就拒绝删除把原因写清楚告诉前端。第三个坑是跨域配置。前后端分离项目里前端和后端端口不同最常见的做法是后端允许所有来源跨域。但如果开启了 allowCredentials(true) 用来传递 cookie那么 allowedOrigins 就不能配成 *浏览器会直接报错。正确写法是允许具体的前端域名或者用 allowedOriginPatterns。另外Spring Security 的拦截链里必须对 CORS 预检请求 OPTIONS 放行否则前端会发现后端明明配了跨域但还是请求失败。5.2 性能与安全自查清单上线前我会按下面这份清单过一遍系统管理模块检查项说明密码存储确认没有明文密码BCrypt 成本因子不低于 10越权访问普通用户 token 不能访问 system:user:list 等管理接口逻辑删除范围所有管理表都有 del_flag所有查询 SQL 都带 del_flag0权限缓存一致性角色菜单修改后用户权限缓存能及时失效分页 SQL 参数排序字段不能直接拼用户输入需要白名单校验操作日志脱敏密码、token、身份证字段在日志里要过滤文件上传接口上传接口必须有独立权限标识防止匿名上传超管账号管理超级管理员数量严格控制使用独立强密码管理这些条目看起来琐碎但权限类事故十有八九都出在这些地方。特别是在权限缓存一致性上我建议每次发布涉及权限的变更后主动清空一遍登录用户缓存宁可让用户重新登录也不要让旧权限残留在线。6. 实测下来的一点体会与可扩展方向先说体会。系统管理模块是一个典型的不需要重复造轮子、但必须看懂轮子的模块。用开源框架作为起点是高效的比如可以参考若依这类前后端分离项目代码完整、权限链路清晰能直接拿来改。但我建议至少把表结构、认证流程、权限判断这三块吃透否则遇到定制需求只能瞎加字段、绕开原有设计最后越改越乱。我自己的经验是能不动的地方尽量不动要动的时候先画清楚改动链路只改业务侧不动权限模型。再说两个来自实测项目的对比。一个项目是内部管理系统用户量小我按标准 RBAC 实现没有做数据权限只靠菜单控制完全够用另一个项目是给第三方客户用的运营平台用户量几千部门层级四层我加了数据权限角色表里新增 data_scope 字段并在业务查询里拼接部门条件。同样一个订单查询接口有数据权限版本和无数据权限版本表面看只差了一个 where 子句实际上统计逻辑完全不同。数据权限的 SQL 拼接需要在 Service 层做统一封装不要散到各个 Mapper 里否则每个业务查询都要自己写一遍维护成本极高。然后是扩展方向。第一个方向是数据权限细化在 sys_role 里加 data_scope 字段配合部门表在业务查询时自动追加 SQL 过滤条件。第二个方向是多租户系统管理这需要在所有表加 tenant_id在登录认证时解析租户上下文业务接口的查询默认带上租户过滤。多租户这块我建议最好在项目一开始就决定做不做不要在跑了一年后拖到高峰期再改造。改造的关键不仅在表加 tenant_id更在认证环节登录时要根据用户的租户编码确认身份Redis 缓存 key 也要带 tenantId否则两个租户下同名的用户名会互相覆盖缓存。第三个方向是把操作日志跟消息中间件打通操作日志只负责往队列里丢消费端负责落库和告警既不影响主流程性能也能做实时风险预警。最后分享一个实际操作中的小技巧新项目从零搭建时可以先把用户、角色、菜单、部门、字典、日志这六个子模块的接口和权限标识梳理成一张清单再开始写代码。这张清单既是开发计划也是后面联调时给前端同事的接口契约更是上线前安全测试的检查依据。代码可以抄、框架可以选但权限模型必须自己想清楚。系统管理模块这一章看似平淡往后几乎每一个业务需求都会踩在它上面值得你多花几天把它钉牢。