前后端分离科创项目管理系统:SpringBoot+Vue实战解析
前后端分离的大学生科创项目在线管理系统SpringBoot Vue MyBatis MySQL这套组合拳最近在实验室和毕设圈子里讨论度一直不低。这个项目一开始是我给学院教务科做的内部工具。当时学校科创申报还是纸质表跑流程学生填完交到学院指导老师签完字再送教务处中间催进度全靠打电话归档得翻档案柜。做了这个系统之后申报、审核、中期检查、结题验收全在线上走老师手机上就能批学生能实时看进度教务处导出数据也省事。后来师弟师妹们拿这套源码去改改做毕设、参加比赛反映都不错。这个项目的前后端分离架构、RBAC权限模型、多状态工作流流转都是标准的通用能力换一个业务场景也能复用。如果你正好准备做管理系统类项目或者想找个完整的全栈实战案例练手这篇文章我把核心设计和部署过程完整拆一遍从项目结构到数据库表设计从接口开发到前后端联调再到服务器上线从头到尾过一遍该踩的坑也顺手标出来。1. 项目整体设计与技术选型1.1 为什么选前后端分离架构以前写管理系统很多同学会用Thymeleaf或者JSP后端渲染页面Controller里直接返回ModelAndView。这种方式写小项目其实挺快但到了这种多角色、多状态、带权限控制的系统页面逻辑复杂起来之后前后端代码耦合会越来越重改一个按钮的样式都可能要动后端代码重新部署维护成本很高。这个项目选前后端分离核心原因有三个。第一业务上存在多个客户端场景。学院分管领导和指导老师经常在手机上点两下看看审批进度一个后端Restful API可以同时支撑Web端、移动端甚至以后的小程序端不需要为每个端单独做后端。第二前后端团队可以并行开发后端定义好接口文档前端按接口联调不用等对方。第三将前端静态页面交给Nginx托管、后端独立跑在Tomcat上时遇到高峰期可以单独扩后端实例而静态资源走CDN运行时互不干扰。1.2 核心技术栈选型分析这个项目的技术栈选的是SpringBoot Vue MyBatis MySQL的组合这是国内中小型管理系统最常见的搭配选择它有明确的现实因素。SpringBoot作为后端框架核心价值在于自动配置和简化的依赖管理。以前搭SSH或者SSMspring-mvc.xml、applicationContext.xml、mybatis-config.xml写一堆XML配置光是集成就要折腾大半天。SpringBoot通过starter机制把常用的配置项打包写一个application.yml就能把Web容器、数据源、连接池全部拉起内嵌Tomcat也省去了单独部署Servlet容器的操作这对快速交付项目很有利。Vue作为前端框架选的是Vue2 Vue Router Vuex Element UI。为什么不用Vue3这不是说Vue2更先进而是考虑到大部分高校的教材、网课、毕设参考资料还是Vue2为主Element UI的生态也比较成熟坑相对少团队里新来的学弟能快速上手。如果你自己时间充裕用Vue3 Vite Element Plus重写一遍前端技术栈更现代部署上差别不大。MyBatis在持久层框架里的定位偏轻量SQL是开发者手写的可控性强。这个系统里有大量多表联合查询、分组统计的SQL比如按学院统计项目申报数量、按年度汇总结题情况MyBatis写这些复杂SQL比JPA的自动生成更直观。后期做SQL调优、加索引、查执行计划手写SQL也更方便。MySQL 5.7是稳定期的版本InnoDB引擎支持事务满足项目申报这种涉及多用户并发写入的场景。字符集统一用utf8mb4可以兼容手机号里可能出现的特殊字符。1.3 功能模块划分这个系统按角色来划分功能模块主要分为学生端、指导老师端、学院管理员端、校级管理员端四个视角。登录注册支持学号/工号登录验证码校验JWT生成token项目管理学生在线申报大创项目填写项目类型创新训练、创业训练、创业实践、成员信息、指导老师、研究周期审批流转指导老师审核打回、学院审核、学校终审每个节点有独立的意见填写和状态流转记录中期检查项目执行一半时提交中期报告、阶段性成果、经费使用情况结题验收提交结题申请、结题报告、成果材料论文、专利、软著扫描件数据统计各学院申报数、立项率、结题率等统计图表系统管理用户管理、角色管理、菜单权限管理、操作日志流程上项目从申报到结题经历的状态有待指导老师审核 - 待学院审核 - 待学校审核 - 已立项 - 中期检查中 - 中期已通过 - 待结题验收 - 已结题 / 被驳回。状态变更的地方都记录了操作日志方便追溯。2. 后端核心设计与实现2.1 SpringBoot项目结构整个后端工程我习惯按模块分包而不是按层分包。按层分包是com.xxx.controller、com.xxx.service、com.xxx.dao全部堆在一起项目一大了找某个业务功能的代码要跨好几个包来回跳。按模块分包之后一个业务模块就是一个完整的功能闭环内部的controller、service、mapper都在同一个包下改起来很顺手。com.example.innovation ├── common // 通用模块统一返回结果、异常处理、工具类 │ ├── result │ ├── exception │ └── utils ├── config // 配置类跨域、拦截器、MyBatis配置 ├── security // 认证与权限 │ ├── JwtUtil.java │ ├── LoginInterceptor.java │ └── AdminInterceptor.java ├── module │ ├── user // 用户模块 │ │ ├── UserController.java │ │ ├── UserService.java │ │ └── UserMapper.java │ ├── project // 项目模块 │ ├── review // 审批模块 │ ├── file // 文件上传模块 │ └── statistics // 统计模块 └── system // 系统管理菜单、角色、日志后端统一返回Result对象结构是{code: 200, message: success, data: ...}code为200是成功401表示未认证403表示无权限500是服务异常。前端axios响应拦截器统一判断code少写很多重复代码。2.2 用户认证与权限控制用户认证用的是JWTJSON Web Token方案。登录时服务端验证学号和密码密码用BCrypt加密后存储验证通过后生成token返回前端。token的内容包含userId、roleCode、expireTime签名时用HMAC-SHA256加密。前端将token存在localStorage里每次请求在axios请求拦截器中加上Authorization: Bearer 头。后端写了一个LoginInterceptor在SpringMVC的拦截器链中注册拦截所有/api/**请求除了登录接口和静态资源放行。public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { // token过期或非法 } } response.setStatus(401); return false; }权限控制上分两级粗粒度控制通过拦截器拦截不同URL前缀实现/api/admin/**只允许管理员访问细粒度控制在Service层做比如学生修改项目信息时先校验项目归属不是项目负责人的直接抛出403异常。很多人只做了拦截器层级的权限控制忽略了数据级权限结果学生把自己的id改成别人的id就能改别人的项目这是很常见的越权漏洞。2.3 项目申报与审批流程设计项目申报流程是这个系统的核心业务我把它设计成了状态机模式。数据库中project表用project_status字段存当前状态状态值用整数编码0-待指导老师审核1-待学院审核2-待学校审核3-已立项4-中期检查中5-中期通过6-待结题7-已结题8-已驳回。状态流转的动作统一封装在ProjectStateMachine这个类里每个状态都定义允许执行的操作和下一个状态非法操作直接抛异常。这种设计比在Controller里写一堆if-else判断状态要清晰得多。举个例子学生提交申报后状态为0指导老师审核通过后状态变为1如果不通过则变为8并附带驳回原因。学生修改后重新提交状态回到1。这一步看起来简单但做了状态机约束后用户再怎么乱点也不会出现状态错乱。审批节点需要保存操作记录我用了一张review_record表字段包括project_id、reviewer_id、review_role、action(agree/reject)、comment、create_time。后期做督办、统计老师平均审批时长都直接查这张表。2.4 文件上传与附件管理项目申报界面上有上传申报书附件、知识产权证明等需求。文件上传我用的本地磁盘存储方案上传路径配置成application.yml里的file.upload-dir字段生成UUID文件名保存原始文件名映射文件大小限制为20MB支持pdf、docx、zip格式。file: upload-dir: /data/innovation/files max-size: 20MB allowed-extensions: pdf,doc,docx,zip,jpg,png上传成功后返回文件的访问路径前端拿着路径展示下载链接。文件下载接口会做登录校验防止未登录用户直接通过文件URL访问服务器上的附件资源。注意生产环境文件不能放在应用目录下否则重新部署的时候文件会被覆盖。一定要放在独立的目录并且建议定期备份。如果项目迁移到云服务器可以考虑改为对象存储如阿里云OSS代码改动不大FileService做个接口适配层就行。3. 前端工程化与Vue实现3.1 Vue环境搭建与工程创建前端工程用Vue CLI创建Node版本要求12以上。装Vue CLI时很多同学直接npm install -g vue/cli安装过程中卡住的八成是npm源的问题设置一下镜像源可以快不少。npm config set registry https://registry.npmmirror.com npm install -g vue/cli vue create innovation-web创建项目时选择Manually select features勾选Router、Vuex。为什么手动选而不直接用默认模板因为默认模板不包含Vuex后面做用户状态管理还得自己装不如一开始就勾上。项目目录里src/views放页面组件src/router/index.js配置路由src/store模块化管理用户信息、菜单权限src/utils/request.js封装axios实例src/api目录下按模块拆接口函数。3.2 路由设计与页面权限控制路由分为静态路由和动态路由。静态路由是登录页、403页、404页这类公共页面动态路由根据用户的角色权限表登录成功后从后端接口拉取菜单数据再通过router.addRoutes动态添加。项目中的角色类型有ROLE_STUDENT、ROLE_TEACHER、ROLE_COLLEGE_ADMIN、ROLE_SCHOOL_ADMIN动态路由就是照着这个列表来渲染侧边栏菜单。// 路由守卫 router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else { if (!token) { next(/login) } else { const role localStorage.getItem(role) if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) } else { next() } } } })这样一个简单的路由守卫能挡住大部分未授权访问但前端的控制本质上只是交互体验层面的真正的安全校验还是要靠后端接口来控制。前端隐藏按钮只意味着你看不到入口不代表后端接口不存在所以我在后端的Service层同样做了权限校验双保险。3.3 核心页面实现要点项目申报页面是学生最核心的界面表单字段比较多我拆成了两个步骤来做第一步填项目基本信息项目名称、类型、周期、简介第二步添加团队成员和指导老师。为什么拆步因为科创项目的成员是多人直接一整个长表单提交会有点乱分步让用户每步都轻量更能避免填到一半超时丢数据。Element UI的el-form搭配rules校验规则项目名称必填且不超过50字成员学号用正则校验指导老师从后端拉取教师列表供选择。项目列表页用了el-table展示列包括项目名称、项目类型、负责人、指导老师、当前状态、申报时间。状态列用el-tag显示不同状态不同颜色。顶部放了筛选条件包括项目类型、状态、时间范围条件变化后重新调用接口查询。这个列表是后端分页传给后端的参数是pageNum、pageSize、projectName、status、type响应数据是分页对象{total, records}。仪表盘页面展示统计图表用的是ECharts。学院管理员登录后能看到本学院的申报趋势柱状图、各专业申报数量饼图、状态分布环形图。ECharts图表数据由后端统计接口提供返回给前端的就是普通的JSON数组前端配置好option就能渲染。有些细节要注意容器容器要有明确的宽高否则图表渲染不出来。4. 数据库设计与MyBatis实战4.1 核心数据表结构设计数据库设计是整个系统的地基表间关系没理清后面写SQL就是无底洞。这个项目一共14张表核心的是下面5张。用户表user字段名类型说明idbigint主键usernamevarchar(50)学号/工号passwordvarchar(100)BCrypt加密存储real_namevarchar(50)姓名role_codevarchar(30)角色编码college_idbigint所属学院phonevarchar(20)联系方式emailvarchar(50)邮箱statustinyint1启用0禁用项目表project字段名类型说明idbigint主键project_namevarchar(200)项目名称project_typetinyint1创新训练2创业训练3创业实践leader_idbigint负责人teacher_idbigint指导老师college_idbigint所属学院project_statustinyint状态码apply_yearvarchar(10)申报年度descriptiontext项目简介create_timedatetime申报时间update_timedatetime更新时间项目成员表project_member字段名类型说明idbigint主键project_idbigint项目IDstudent_idbigint学生IDmember_rolevarchar(20)成员角色join_timedatetime加入时间审批记录表review_record、中期检查表midterm_report、结题验收表final_report这些按业务需要继续扩展。核心原则是一张表只存储一个业务实体的数据项目主体信息在project表成员在project_member表审批轨迹在review_record表三者通过外键关联不冗余存储。4.2 MyBatis配置与通用Mapper项目MyBatis用的SpringBoot集成方式在pom.xml中加入mybatis-spring-boot-starter依赖application.yml中配置mapper-locations扫描XML文件。dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.2.2/version /dependencymybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.innovation.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置很重要它能自动把数据库的snake_case字段映射到Java的camelCase属性比如project_name映射到projectName省去手动写resultMap的繁琐配置。日志配置为StdOutImpl后开发阶段控制台会打印SQL和参数调试非常方便但生产环境记得关掉否则日志会大量刷SQL。单表CRUD我用的通用Mappertk.mybatis继承Mapper 接口后基本的selectByPrimaryKey, insert, updateByPrimaryKeySelective, deleteByPrimaryKey就不用写了。复杂查询还是手写XML两种方式结合效率和可控性都兼顾。4.3 多条件分布式查询与分页实现项目列表页的多条件查询后端接收pageNum、pageSize、projectName、status、type、collegeId等参数。SQL写在ProjectMapper.xml里用动态标签拼接条件。select idselectProjectPage resultTypecom.example.innovation.entity.Project SELECT p.*, u.real_name as leader_name, t.real_name as teacher_name FROM project p LEFT JOIN user u ON p.leader_id u.id LEFT JOIN user t ON p.teacher_id t.id where if testprojectName ! null and projectName ! AND p.project_name LIKE CONCAT(%, #{projectName}, %) /if if teststatus ! null AND p.project_status #{status} /if if testtype ! null AND p.project_type #{type} /if if testcollegeId ! null AND p.college_id #{collegeId} /if /where ORDER BY p.create_time DESC /select分页用PageHelper插件一行代码搞定PageHelper.startPage(pageNum, pageSize); ListProject list projectMapper.selectProjectPage(query); PageInfoProject pageInfo new PageInfo(list);PageHelper的原理是拦截Executor自动拼接LIMIT语句并执行COUNT查询。要注意PageHelper.startPage必须紧跟查询语句中间不能有别的数据库操作否则分页会失效。这也是很多初级开发遇到的坑习惯性地在startPage和query之间加了一个查询用户信息的操作结果分页全部失效。5. 完整部署流程与配置5.1 部署环境准备部署主要分为三种场景本地开发环境、服务器测试环境、正式生产环境。这里我用一台CentOS 7系统的服务器举例2核4G配置足够跑这个系统。需要的依赖环境组件版本要求部署方式JDK1.8yum安装或tar包解压MySQL5.7docker容器或rpm安装Nginx1.16yum安装Node.js12仅构建时需要服务器可不需要后端是jar包运行内嵌了Tomcat服务器上只需要装JDK和MySQL。前端的Vue项目构建后生成静态文件由Nginx托管。整体架构很简单不需要额外装Tomcat。5.2 后端打包与启动后端工程用Maven打包跳过测试直接打jar包mvn clean package -DskipTests打出来的jar在target目录下名字类似于innovation-server-1.0.0.jar。启动之前先改一下application-prod.yml把数据库连接、文件上传路径、日志路径都改成生产环境的值。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/innovation_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver为什么serverTimezone一定要写Asia/Shanghai因为MySQL驱动8.x版本默认时区是UTC如果我们用的是中国时区不指定时区的情况下数据库里存的时间会比本地时间晚8个小时查出来对不上。这个坑我踩过好几次每次都是查数据库时间发现差8小时才想起时区配置。启动命令用nohupnohup java -jar innovation-server-1.0.0.jar --spring.profiles.activeprod /dev/null 21 查看启动日志确认没有报错然后用curl或者浏览器访问后端接口验证。5.3 前端构建与Nginx配置前端构建之前先把接口地址的配置改掉。项目里src/utils/request.js的baseURL开发环境是/api通过Vite代理转发到localhost:8080。生产环境这个baseURL还是/api但Nginx配置会把这个路径反向代理到后端服务。npm install npm run build构建完成后dist目录就是所有静态文件。把dist目录上传到服务器例如/data/innovation/dist。然后配置Nginxserver { listen 80; server_name your_domain_or_ip; # 前端静态文件 location / { root /data/innovation/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传文件访问 location /files/ { alias /data/innovation/files/; } }try_files $uri $uri/ /index.html这个配置是SPA应用的关键。Vue是单页应用路由跳转是前端history模式刷新页面时如果直接请求的是前端路由路径比如/project/123Nginx在dist目录下找不到这个文件就会返回404。try_files配置让Nginx在找不到文件的时候降级返回index.html由前端路由逻辑接管这样刷新就不会白屏了。location /api/的反向代理要注意proxy_pass后面有没有末尾的/这个斜杠有无差别很大。如果proxy_pass http://127.0.0.1:8080;则请求/api/project/list会被完整转发到后端后端接口路径就是/api/project/list如果proxy_pass http://127.0.0.1:8080/;则/api前缀会被去掉转发成/project/list。两种方式都行但必须和后端Controller的路由保持一致。Nginx配置改完后重载nginx -t nginx -s reload5.4 服务器环境的数据库脚本初始化部署时如果数据库是空的需要先导入初始化SQL。项目里我把数据库脚本放在doc/sql/init.sql包含建库语句、建表语句、初始数据。初始数据里有一个admin账号密码经过BCrypt加密直接登录后台可以使用。导入命令mysql -u root -p init.sql这里有一个建议生产环境不要把数据库root密码明文写在配置里github仓库里更不要提交真实密码。我在项目中是把配置文件外置在jar包同级目录放config/application-prod.ymlSpringBoot启动时会优先加载外部配置这样打包的jar里只需要保留开发环境配置。6. 常见问题与排查技巧实录6.1 前后端联调中的跨域问题开发环境中前端跑在8081端口后端跑在8080端口浏览器直接请求会出现跨域。最常见的报错是Access to XMLHttpRequest has been blocked by CORS policy。后端我在config包下写了CorsConfig类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }但要注意如果前端请求走了Nginx代理跨域问题其实在Nginx层就解决了因为前后端同域。所以开发环境遇到跨域优先检查CORS配置生产环境遇到跨域优先检查Nginx代理路径是否正确。6.2 接口返回401或403排查后端接口测试正常集成到前端就返回401八成是token没有传或者token过期。用浏览器的开发者工具Network面板点击请求看Request Headers里Authorization头是否存在。如果不存在检查axios拦截器里是否配置了请求头。403多半是角色权限不匹配。比如学生角色访问了/admin开头的接口后端拦截器直接拒绝。排查时先确认当前用户的角色再看接口路径是否匹配权限。另一种情况是接口路径匹配了但在Service层做了数据归属校验当前用户不是数据的所有者也会抛出403。这类问题日志中会有明确提示。6.3 连接数据库报错Communications link failure这个报错是开发中最常见的。原因通常有几种MySQL服务没有启动service mysqld status查看状态端口不对默认3306如果改了端口连接串也要改防火墙拦截服务器上telnet 127.0.0.1 3306测试连接串的serverTimezone写错MySQL密码不对排查顺序建议先确认服务是否在运行再ping端口最后验证账号密码。有一次师弟的数据库连接失败查了半天发现是MySQL里root账号的host配置成了localhost而程序用127.0.0.1连接被拒了。把host改成%或者直接用localhost就能解决。6.4 Vue依赖安装报错或构建失败npm install过程经常出现的问题第一个是node-sass安装失败。node-sass是个老牌依赖每次Node版本一升级就容易编译报错建议换sass也就是dart-sass的实现安装速度快兼容性也好。第二个是npm ERR! code ELIFECYCLE报错通常是依赖版本冲突可以删除node_modules目录和package-lock.json后重新安装。第三个是vue-cli创建项目报错关于Node版本Node版本过高或过低都可能导致尽量用Node 16左右的LTS版本比较稳。构建失败如果卡在Babel编译阶段看报错提示是不是某个组件语法不兼容这种情况多数是装了一个太新的依赖包而组件库版本跟不上。锁版本号是解决这类问题最简单粗暴的方式。6.5 服务器部署后页面白屏前端部署到服务器后页面白屏打开控制台发现报错是Failed to load resource: the server responded with a status of 404或者找不到某个JS/CSS文件。这个问题的根源通常是静态资源路径配置不对。Vue项目构建后的资源路径默认是绝对路径/如果Nginx把dist目录作为站点根目录路径是没问题的。但如果你的系统部署在一个子路径下比如http://server/innovation/这种访问方式那就要在vue.config.js里配置publicPath: ./或者具体的子路径。同时router也要设置base参数否则路由跳转的路径也会对不上。另一个常见白屏原因是开发环境访问正常但生产环境白屏。这通常是因为代码中引用了某些浏览器不兼容的API或者原生代码里有跨域调用生产环境无法访问。用浏览器控制台看具体的报错信息按图索骥。6.6 文件上传失败排查文件上传失败先看报错是前端拦截的还是后端返回的。前端拦截常见的是文件扩展名不在允许列表或者文件超出大小限制。后端返回异常通常是静态资源目录没有写权限或者目录不存在。排查上传目录权限ll /data/innovation/files/ chmod 755 /data/innovation/files另外Nginx默认对上传文件的大小有限制如果通过Nginx上传超过1MB的文件直接报413 Request Entity Too Large需要在nginx.conf的http块中加一句client_max_body_size 20m。写在最后前后端分离的完整项目从数据库建模到后端接口开发从前端页面实现到服务器部署每一环节单独看不难但串起来做一遍确实能暴露出不少问题。这个项目最初花了大概三周时间从零到上线中间周末连续调试了两天跨域和文件上传的问题。如果你拿这个项目做毕设或比赛建议优先把流程跑通再在细节上打磨比如消息通知、数据导入导出这些进阶功能可以后续再加上。我个人的体会是这类管理系统最重要的不是炫技而是把业务流程理清楚、状态流转设计严谨然后让代码结构保持干净能让人很快看懂、接手、扩展。以后换成课程管理系统、竞赛管理系统、毕业设计选题系统只要把业务表换一换技术框架基本可以完全复用。这也是我做公共基础模块比较多、不太建议上来就用代码生成器一顿乱撸的原因——代码写好跑通是一回事后期改造与维护才是真正见功夫的地方。希望这份拆解对你能有帮助。