前后端分离这套东西,这些年基本成了企业信息化项目的标配。SpringBoot负责后端接口,Vue负责页面交互,MyBatis操作数据库,MySQL存数据,四件套组合起来,既能满足中小型企业内部管理系统的快速交付需求,又能在后期按模块独立扩容。我这次做的就是一个典型的企业内管信息化系统,包括用户管理、部门管理、角色权限、公告通知、审批流程等核心模块,前后端完全分离,源码结构清晰,附带完整部署文档。这篇文章就把整个项目的设计思路、核心实现、部署过程和踩坑记录一次性讲清楚,适合刚接触前后端分离项目、想看完整闭环的开发者,也适合准备在公司内部落地类似系统的技术负责人参考。
1. 整体设计与思路拆解
1.1 技术选型背后的考量
先说说为什么选这套组合,而不是别的。很多人在做内部管理系统时喜欢一上来就上微服务、上Redis、上消息队列,结果业务还没跑起来,光是基础设施就把团队折腾得够呛。企业内部管理系统有一个很典型的特征:并发量不会太高,但业务逻辑复杂、权限模型繁琐、报表需求多变。这种场景下,单体应用加前后端分离反而是最务实的选择。
SpringBoot的价值在于它把过去SSH时代那一堆繁琐的XML配置全部干掉,内置Tomcat,起步依赖机制让项目初始化变得非常快。对于企业内管系统这种重业务、轻并发的项目,SpringBoot的单体架构完全能撑住几千人规模的公司日常使用。
Vue选择的是Vue 2还是Vue 3?我这次用的是Vue 2 + Element UI。我知道很多人会说Vue 3都出来这么久了,怎么还用Vue 2。原因很简单:Element UI对Vue 2的生态最成熟,后台管理系统常用的表格、表单、树形控件、弹窗组件都是现成的,开发效率极高。如果团队已经熟练Vue 3 + Element Plus,那完全可以直接平移,核心逻辑不变。
MyBatis相比JPA,最大的优势是SQL可控。企业内部系统往往需要处理复杂的多表关联查询、动态条件过滤、报表统计,这些用JPA的自动SQL反而难写,MyBatis可以让你精确掌控每一条SQL语句,配合XML中的动态SQL标签,几乎能应付所有查询场景。MySQL则是稳扎稳打的关系型数据库,事务支持可靠,备份恢复方案成熟,对于企业内管系统来说完全够用。
1.2 前后端分离的架构边界
前后端分离不是简单地把代码分成两个目录,而是要在架构层面明确边界。我这边定的原则是:
后端只负责提供RESTful API,不关心页面渲染。所有接口统一返回JSON格式数据,采用{ "code": 200, "message": "success", "data": {} }这样的统一响应结构。前端只通过Axios调用接口获取数据,不拼接HTML,不做模板渲染。
前后端通过API文档约定接口格式。我用的方式是Swagger自动生成接口文档,前端直接查看在线文档调试接口。这样避免了两边扯皮,后端写完接口自测通过,前端拿着文档就能并行开发。
跨域问题需要在后端统一解决。开发环境下前端跑在8080端口,后端跑在8081端口,必然存在跨域。我在后端配置了全局CORS过滤器,允许指定来源跨域请求,同时支持携带认证Token。生产环境则通过Nginx反向代理解决,前端请求/api开头的地址,Nginx统一转发到后端服务,这样生产环境根本没有跨域问题。
1.3 核心模块与功能拆解
整个系统我拆成了六个核心模块:
- 系统管理模块:用户管理、部门管理、岗位管理、菜单管理、角色管理。这是整个系统的地基,所有权限控制都建立在这套RBAC模型之上。
- 公告通知模块:发布内部公告、通知,支持指定接收范围,已读未读状态跟踪。
- 审批流程模块:请假申请、报销申请、通用审批流,支持多级审批和审批记录追踪。
- 内容管理模块:企业新闻、规章制度、文档资料的上传与分类管理。
- 个人中心模块:修改个人信息、修改密码、查看我的待办、我的申请。
- 系统监控模块:登录日志、操作日志、在线用户管理。
每个模块都是独立的包结构,业务代码互不穿插,这在后期维护时非常舒服。比如要加一个新模块,直接复制一个模块的代码结构改一改就能上手,不用在几千行的代码里找从哪里下手。
2. 数据库设计与核心表结构
2.1 权限模型设计:RBAC怎么落地
企业内管系统的权限模型,基本都逃不开RBAC(基于角色的访问控制)。我这里的实现是五张表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。
用户表和角色表是多对多关系,一个用户可以有多个角色,一个角色也可以分配给多个用户。角色表和菜单表也是多对多关系,一个角色能访问哪些菜单,就是通过角色菜单关联表来确定的。
菜单表的设计稍微讲究一点。我用menu_type字段区分目录、菜单和按钮,parent_id字段构建树形层级,order_num控制排序。按钮级别的权限也挂在菜单表上,比如"用户新增"这个按钮,就是一个menu_type为按钮的记录,父级是"用户管理"这个菜单。这样前端可以精确控制到按钮级别,没有权限的用户连新增按钮都看不到。
2.2 核心表结构参考
用户表的核心字段大概长这样:
CREATE TABLE `sys_user` ( `user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID', `dept_id` bigint DEFAULT NULL COMMENT '部门ID', `username` varchar(30) NOT NULL COMMENT '用户名', `nick_name` varchar(30) NOT NULL COMMENT '昵称', `password` varchar(100) DEFAULT NULL COMMENT '密码(加密存储)', `email` varchar(50) DEFAULT '' COMMENT '邮箱', `phone` varchar(11) DEFAULT '' COMMENT '手机号', `status` char(1) DEFAULT '0' COMMENT '状态(0正常 1停用)', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`user_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='用户信息表';审批流程表的设计我单独说一下。因为要支持多级审批,我用两张表:审批实例表和审批记录表。审批实例表存的是这条申请的整体状态、当前审批节点、申请类型;审批记录表存的是每一步的审批人、审批意见、审批结果、审批时间。这样任何一条申请从头到尾经历了哪些步骤、谁批的、批了什么意见,都能完整追溯。
2.3 字段设计的几个关键点
第一,所有表都加上create_time、update_time、create_by、update_by这四个字段。这在排查数据问题、追责审计时非常重要。曾经遇到过业务部门说某条数据被改错了,就是因为有update_by和update_time,很快定位到是哪个账号在什么时间改的。
第二,逻辑删除而不是物理删除。用del_flag字段标记删除状态,查询时统一加WHERE del_flag = '0'条件。企业内管系统的数据往往是关联的,物理删除会把整条链路的数据完整性破坏掉。
第三,金额字段用DECIMAL(10,2)而不是FLOAT。FLOAT有精度丢失问题,报销金额这种敏感数据出不得半点差错。
3. 后端核心实现解析
3.1 SpringBoot项目结构与启动类
后端的包结构我按功能模块划分,而不是按技术层划分,这样更符合业务直觉:
com.company.system ├── common // 通用工具、常量、异常处理 ├── framework // 框架配置(安全、拦截器、CORS) ├── modules │ ├── system // 系统管理模块 │ ├── notice // 公告通知模块 │ └── workflow // 审批流程模块 └── tools // 工具类(Excel导入导出等)启动类就是标准的SpringBoot入口,用@SpringBootApplication标注,加上@MapperScan扫描MyBatis的Mapper接口。这里有个小细节:@MapperScan的包路径一定要写对,否则Mapper接口扫描不到,启动会直接报错。
3.2 统一响应结构与会话管理
后端所有接口返回统一结构,这是前后端协作的基础:
public class AjaxResult { private int code; private String message; private Object data; public static AjaxResult success() { return new AjaxResult(200, "操作成功", null); } public static AjaxResult success(Object data) { return new AjaxResult(200, "操作成功", data); } public static AjaxResult error(String message) { return new AjaxResult(500, message, null); } }会话管理我使用JWT(JSON Web Token)方案。用户登录成功后,后端生成一个Token返回给前端,前端存储在本地,后续每次请求在Header中携带Authorization: Bearer {token}。后端通过拦截器统一校验Token,解析出用户信息后放到ThreadLocal中,这样在业务代码里可以直接通过工具类获取当前登录用户,不用在每个方法里传参。
选JWT而不是Session,主要有两层考虑。一是前后端分离后,前端可能部署在多个域名下,Session跨域麻烦;二是JWT是无状态的,后端服务重启后用户不需要重新登录。当然JWT也有缺点,就是服务端无法主动让Token失效,所以我在设计时把Token的有效期设为2小时,过期后前端会收到特定错误码,自动跳转到登录页重新认证。
3.3 MyBatis动态SQL实战
MyBatis的XML中动态SQL是这个项目里最常用的功能,尤其是多条件组合查询。比如用户列表查询,需要支持按用户名模糊搜索、按状态筛选、按部门筛选、按时间范围筛选,这些条件可以任意组合:
<select id="selectUserList" parameterType="SysUserQuery" resultMap="SysUserResult"> SELECT u.*, d.dept_name FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id = d.dept_id <where> <if test="username != null and username != ''"> AND u.username LIKE CONCAT('%', #{username}, '%') </if> <if test="status != null and status != ''"> AND u.status = #{status} </if> <if test="deptId != null"> AND u.dept_id = #{deptId} </if> <if test="beginTime != null"> AND u.create_time >= #{beginTime} </if> <if test="endTime != null"> AND u.create_time <= #{endTime} </if> AND u.del_flag = '0' </where> ORDER BY u.create_time DESC </select><where>标签会智能处理条件拼接问题,当前面没有条件时自动去掉AND关键字,避免SQL语法错误。>和<是XML中的特殊字符转义,这个是新手最容易踩的坑,写>在XML里会直接报解析错误。
3.4 接口权限控制
接口权限的实现方式比较直接。我自定义了一个@RequiresPermission注解,标注在Controller方法上,声明访问这个接口需要的权限标识符。拦截器在用户请求到达Controller之前,先从数据库查出该用户的角色对应的所有权限标识,再和注解要求的权限标识做匹配。不匹配就返回403错误码。
你可以看到类似这样的代码:
@RequiresPermission("system:user:add") @PostMapping("/user") public AjaxResult add(@RequestBody SysUser user) { return userService.insertUser(user); }这样做的优势是权限控制的粒度可以精确到单个接口操作。菜单级别决定用户看得到哪些页面,按钮级别的权限标识则决定用户能不能执行某个操作,两层叠加就是完整的权限闭环。
4. 前端核心实现解析
4.1 Vue项目结构与路由设计
前端我用Vue CLI初始化项目,配合Vue Router和Vuex。项目结构如下:
src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── layout // 主布局框架 ├── router // 路由配置 ├── store // Vuex状态管理 ├── utils // 工具函数 └── views // 页面组件路由设计上我用了动态路由的方案。用户登录后,后端返回该用户有权限访问的菜单列表,前端把菜单列表转换成路由配置,通过router.addRoutes动态添加。这样没有权限的页面,前端路由根本不会注册,就算手动输入URL也无法访问。
菜单和路由的对应关系在菜单表中通过path和component字段维护。component字段存的是前端组件的路径字符串,前端拿到后通过import()方法动态加载组件。这里需要注意,组件路径必须是相对views目录的路径,且大小写要一致,否则动态加载会失败。
4.2 Axios统一封装与Token刷新
所有接口请求我都走统一的Axios实例,在request.js中做了两层拦截:
请求拦截器在每次请求前从localStorage获取Token,拼到Header上。响应拦截器统一处理后端返回结果,当响应码为200时直接解包返回data字段,让业务代码不用每次判断;当响应码为401时说明Token过期,清空本地登录状态并跳转到登录页。
实际使用中还遇到一个很典型的问题:Token在请求过程中恰好过期。比如用户正在填写一个很长的审批表单,填了半小时,点提交时Token已经失效。如果直接跳登录页,用户填的内容全丢了,体验很差。我的处理方式是在响应拦截器中遇到Token过期时,先尝试调用刷新Token接口,用旧的Token换取新的Token,然后重新发起刚才失败的请求。只有刷新失败才跳转登录页。这个小优化在内部系统中非常实用,员工不会因为挂机一会儿就被强制踢出去。
4.3 核心页面实现逻辑
用户管理页面是系统管理中最复杂的页面,因为它需要同时展示用户信息和关联的部门、角色信息。页面结构是左侧部门树,右侧用户表格,点击部门树节点时刷新右侧表格,按部门ID过滤用户。涉及的核心知识是树形组件和表格组件的联动,以及分页查询的参数管理。
角色管理页面的核心是权限分配。我实现了一个权限树弹窗,树形结构是菜单表数据,节点可以勾选,勾选结果就是角色拥有的权限集合。提交时将勾选的菜单ID列表组装成数组传给后端,后端先删除该角色原有的关联记录,再批量插入新的关联记录。这种"先删后插"的方式实现简单、逻辑清晰,数据量也不大,不需要复杂的增量对比。使用中发现删除和插入操作需要放在同一个事务中,否则中途出错会导致权限数据不完整,用户在登录时会直接报错。
公告管理页面用到了富文本编辑器,这里建议用简洁的编辑器方案。内部系统还有很多内容需要处理,比如PDF预览、Word在线预览,这些后续都可以在模块中继续扩展。
5. 部署实战与配置详解
5.1 本地开发环境搭建
开发环境的配置比较直接。后端项目使用Maven构建,需要JDK 1.8以上、Maven 3.6以上。在application.yml中配置数据库连接、Redis连接等参数。这里我解释一下为什么需要Redis——虽然数据库是MySQL,但JWT的登录状态、验证码存储这些需要高速读写的场景,用Redis比查数据库更合适。实现验证码功能时,后端生成一个UUID作为key,把验证码文本存入Redis并设置过期时间,图片返回给前端。前端提交登录请求时带上UUID和用户输入的验证码,后端用UUID去Redis查,比对是否一致、是否过期。
本地开发时后端启动在8081端口,前端通过npm run dev启动在8080端口。前端.env.development文件中配置VUE_APP_BASE_API为http://localhost:8081,Axios的baseURL就从这里读取。
5.2 服务器部署核心要点
生产环境我用的方案是:一台Linux服务器 + Nginx + MySQL + SpringBoot Jar包。
打包流程分两步。后端在项目根目录执行mvn clean package -DskipTests,生成system-admin.jar。前端在src目录执行npm run build,生成dist目录,里面是纯静态文件。
Nginx配置是关键。我的配置思路是:监听80端口,根路径指向前端dist目录,所有/api路径的请求反向代理到后端的8081端口。同时需要配置前端路由的try_files指令,这一步很容易遗漏,导致刷新页面时404。原因在于Vue Router使用history模式,路由路径在刷新时会被Nginx当成文件路径去找,找不到就404了。配置如下:
server { listen 80; server_name your-domain.com; root /opt/system/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }proxy_pass http://127.0.0.1:8081/;后面的斜杠需要注意。如果写成http://127.0.0.1:8081(没有斜杠),则会把请求路径原样转发,前端请求/api/user/list会变成后端的/api/user/list;而带斜杠会把/api前缀去掉,变成/user/list。所以你在设计后端接口时,要看Controller类上的@RequestMapping是否有/api前缀。我这边是统一加了/api前缀,所以Nginx中不带斜杠转发,后端照单全收。两种方式都可以,关键是前后端保持一致,不要搞混。
MySQL部署时建议单独建一个数据库用户,只授予这个库的权限,不要用root账户连接应用。数据库编码设置为utf8mb4,因为企业内管系统中员工可能输入各种特殊字符和表情,utf8mb4才能完整存储这些字符,utf8在遇到部分生僻字时会报编码错误。
5.3 生产环境启动脚本与守护进程
后端Jar包不能直接用java -jar前台运行,否则关掉SSH窗口进程就死了。我写了一个启动脚本实现后台运行和环境变量配置:
#!/bin/bash APP_NAME=system-admin.jar LOG_FILE=/opt/system/logs/system.log start() { nohup java -Xms512m -Xmx1024m \ -Dspring.profiles.active=prod \ -jar $APP_NAME \ > $LOG_FILE 2>&1 & echo "Application started, pid: $!" } stop() { pid=$(ps -ef | grep $APP_NAME | grep -v grep | awk '{print $2}') if [ -n "$pid" ]; then kill -9 $pid echo "Application stopped" else echo "Application not running" fi } case "$1" in start) start ;; stop) stop ;; restart) stop && sleep 2 && start ;; *) echo "Usage: $0 {start|stop|restart}" ;; esac-Xms512m -Xmx1024m设置了堆内存初始512MB、最大1024MB。2GB内存的服务器同时跑MySQL和这个应用,我用下来基本稳定,内存占用在80%以内。如果你使用nohup方式,日志要放到单独目录并配合logrotate做日志轮转,不然时间久了日志文件会撑满磁盘。
还有个更健壮的方案是使用systemd管理进程,配置Restart=always自动拉起,服务器重启后服务自动恢复。这个在企业环境比手动脚本更省心,推荐有条件的直接上systemd。
5.4 数据库初始化与版本管理
首次部署需要执行数据库初始化脚本。我把所有建表语句和基础数据(比如admin账号、默认角色、菜单数据)放在sql/init.sql中,保证一个脚本即可让空库变成可运行环境。注意这个脚本要在应用启动前先执行,因为MyBatis启动时会做数据库连接校验,如果表不存在且没有设置ddl-auto为自动建表,启动会直接失败。
更规范的做法是引入Flyway做数据库版本管理。每次表结构变更都写一个新的迁移脚本,Flyway在应用启动时自动执行未运行过的脚本,团队协作时大家不会因为忘了执行SQL导致本地环境不一致。虽然这个项目一开始没用到Flyway,但后续如果要多人协作开发,强烈建议引入。
6. 常见问题与排查技巧实录
6.1 前端登录后立即失效
这个坑我印象最深,排查过程也最典型。现象是用户登录成功,跳转到首页,但一刷新页面就跳回登录页。起初怀疑Token存储有问题,检查发现localStorage中Token是存在的。继续排查,发现是路由守卫里对登录状态的判断:我判断用户是否登录的标准是localStorage中是否存在Token,但用户信息没存完整,导致有Token但缺少用户信息时,路由守卫认为未登录,强制跳转。
解决办法是把用户信息在登录后一起存入localStorage,路由守卫同时校验Token和用户信息。另一个原因是后端返回的Token里包含了用户ID,前端解析Token时如果密钥不一致,也会校验失败。
6.2 MyBatis批量插入报SQL语法错误
做角色分配权限时,批量插入用户角色关联数据,SQL写成:
INSERT INTO sys_user_role (user_id, role_id) VALUES <foreach collection="roleIds" item="roleId" separator=","> (#{userId}, #{roleId}) </foreach>报错信息是"check the manual that corresponds to your MySQL server version"。排查发现MySQL连接串漏了allowMultiQueries=true参数。这个参数允许一条语句中包含多条SQL,但批量插入实际上是一条INSERT带多个VALUES,按道理不需要这个参数。继续排查,最后发现问题出在驱动版本和数据库版本不兼容,Maven中引用的MySQL驱动还是5.x,而数据库是8.x,驱动升级到8.0.33后问题消失。
所以遇到这类问题,先检查驱动版本。5.x驱动连8.x数据库,大部分功能都正常工作,但部分SQL语法和认证方式会有兼容性问题,表现就是报各种莫名其妙的SQL异常。
6.3 文件上传大小超限
公告模块附件上传时,超过1MB的文件会报错。SpringBoot默认的单文件上传大小限制是1MB,需要在配置中调整:
spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB有个容易忽略的关联配置是:如果Nginx层也设置了上传限制,client_max_body_size默认是1MB,用户上传大文件时会在请求到达后端之前就被拦截,返回413错误。如果你已经改了Spring配置但上传还是失败,就去检查Nginx的client_max_body_size,两个地方的配置需要保持一致。
6.4 权限修改后不生效
管理员修改了用户的角色,但该用户重新登录后依然看不到新权限。原因在于JWT机制下的权限数据缓存策略。我最初的方案是在用户登录时将权限标识列表写入Redis缓存,后续每次校验直接从缓存读取,大大提高了鉴权速度。但当角色权限变更时,如果只更新数据库不清理缓存,老缓存依然生效。
解决方案是在权限变更的Service方法里,清除对该用户的缓存。我加了一个简单的做法:修改角色权限后,调用工具类清空所有用户的权限缓存,让用户在下次请求时重新从数据库加载。虽然简单粗暴,但内部系统用户量不大,这样处理不会造成性能问题,反而避免了大量逻辑判断。
6.5 数据库连接池连接耗尽
某个周五下午突然收到告警:后端大量请求超时,日志显示无法获取数据库连接。排查后发现是一个定时任务中开启了事务但循环里查数据太慢,长事务占用了连接不放。加上当时连接池配置的maximum-pool-size是10,高峰期瞬间就打满了。
问题分两层处理。第一层是优化定时任务,查询分批处理,每批次提交事务,不在一个事务里跑全量。第二层是把连接池参数调整到maximum-pool-size: 50,minimum-idle: 10,connection-timeout: 30000。调整后高峰期连接池监控峰值在20多个,足够用了。
7. 实践经验与扩展设想
这个系统从零搭建到上线运行,我自己在过程中最大的体会是:前后端分离的难点不在某个单独的技术点,而在整个链路的衔接。数据库的主键策略、后端的统一响应结构、前端的请求封装、Nginx的路径转发,每一个环节都是独立的,但合起来才是完整可用的一套系统。但凡其中一个环节约定不一致,排查起来就要沿着整个链路从头到尾捋一遍。
代码规范方面,我强烈建议从第一天就定下统一风格。比如Controller层只做参数接收和结果返回、Service层写业务逻辑、Mapper层只负责SQL,三层结构不要混着写。很多后期维护的痛苦都是因为有人在Controller里直接写了SQL,有人把业务逻辑塞进了Mapper,代码越改越乱,最后谁都不敢动。
几个可以继续扩展的方向:加上WebSocket实现站内消息实时推送,用Redis改造现有缓存提高性能,加入Elasticsearch实现全文搜索,配合Jenkins实现CI/CD自动部署。内部管理系统的天花板不低,但地基稳了,上面的功能才能不断叠。
最后分享一个小细节:上线前把数据库的sql_mode设置为严格模式,并把ONLY_FULL_GROUP_BY加上。这个配置能有效避免开发环境宽松、生产环境严格的坑,同时检查所有字段的默认值。我见过太多因为字段默认值不一致导致的线上数据问题,这一步检查做完,系统上线后的数据质量会高一个档次。