聊到计算机毕业设计,SpringBoot + Vue这套组合近几年几乎是默认选项了。我手里这个项目是一个毕业设计管理系统,面向毕业论文设计项目的全流程管理:从教师发布题目、学生选题、提交论文,到答辩分组、成绩录入,再到管理员在后台统一调控。整套系统用SpringBoot做后端接口,Vue做前端页面,数据落在MySQL里,前后端通过JSON交互。适合正在准备毕设的同学直接参考复现,也适合想了解前后端分离项目怎么落地的开发者拿来做技术底稿。
这个项目最值得看的不是功能有多花哨,而是它把毕业设计管理的典型流程完整串起来了。相比网上那些只有登录注册的“半成品”源码,这套系统真正涉及了业务状态流转、角色权限隔离、文件上传、答辩排期这几个硬骨头。拿下这些点,毕业答辩时被问到的质量会高很多,也说明你对SpringBoot和Vue的理解不止停留在增删改查层面。
1. 项目整体设计与技术选型
1.1 这个系统到底解决什么问题
每年毕业季,论文选题、开题报告、中期检查、论文定稿、答辩分组、成绩汇总,这一串流程全靠人工在微信群里靠接龙和Excel表格管理,结果就是一团乱麻。学生反复问“老师我选哪个题”“答辩几点开始”,老师反复在群文件里捞表,管理员更是要一个人对着几百条记录核对。这个系统就是把这一整条链路搬到了线上。
我按业务参与者把系统分成三个端来看,会更清晰:
- 学生端:浏览指导老师发布的选题,在线提交选题申请;上传论文初稿、终稿,查看审核状态;查看自己的答辩时间和分组。
- 教师端:发布选题、设置选题人数上限;审核学生的选题申请;下载并评阅学生论文,填写评语和成绩;参与答辩评分。
- 管理员端:维护用户信息和院系专业数据;管理答辩小组和答辩时间场地;查看和导出全流程统计报表。
本质上这就是一个带状态流转的业务管理系统,不是单纯的信息展示。这也是为什么“选题管理”“论文提交”“答辩分组”这三个模块必须设计好,因为它们是这一领域区别于普通CRUD系统的关键。
1.2 为什么是SpringBoot + Vue,而不是老一套
很多学校的毕设还在用JSP + Servlet + JDBC那一套,不是说不能做,而是这套组合在今天看来开发效率和维护成本都不占优势。SpringBoot最核心的价值是“约定大于配置”,内嵌Tomcat,不用再为环境配置折腾两三天,一个spring-boot-starter-web依赖就能把Web项目跑起来。这对时间紧迫的毕设来说,省下来的时间都是实打实的。
Vue这边解决的是页面交互复杂的问题。毕设管理系统的角色多、页面多,如果用服务端渲染,每次切换菜单都要刷新整个页面,体验很生硬。Vue的双向绑定和组件化开发,让表单校验、列表渲染、状态切换这些操作变得非常顺手。再加上Element-UI这类组件库,表格、弹窗、分页、表单校验基本是“拿来即用”。
我做一个选型对照,大家应该能看出差别:
| 对比项 | JSP + Servlet + JDBC | SpringBoot + Vue前后端分离 |
|---|---|---|
| 环境搭建 | 手动配置web.xml、spring-mvc.xml,步骤多 | 依赖自动配置,起步快 |
| 前后端耦合 | JSP页面里写Java代码,改样式容易互相影响 | 前后端完全分离,接口联调 |
| 开发效率 | 页面交互逻辑用jQuery手写,代码量大 | Vue指令、组件化,复用性高 |
| 部署方式 | 打包war扔进Tomcat | 后端jar + 前端静态资源,可单端口托管 |
| 面试含金量 | 偏传统 | 更贴近当前企业主流技术栈 |
很多同学担心学不会Vue,其实毕设用到Vue的部分大多是“template + script + style”三段式的组件写法,路由用Vue Router,状态管理偶尔用Vuex或Pinia,这些知识跟一遍官方文档就能上手。真正要下功夫的是接口设计和业务逻辑,而不是反复纠结某个API怎么调。
1.3 推荐的技术栈清单
我按自己常用的组合列了一份技术栈,稳定性优先,不追求花哨版本:
- 后端:JDK 1.8 + SpringBoot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8.0
- 安全方案:Sa-Token 或 JWT(理由后面单独写)
- 前端:Vue 2.7 + Element-UI + Axios + Vue Router
- 构建工具:Maven 3.8 + npm
- 开发工具:IDEA + VSCode + Navicat
这里有个坑要提醒一下:SpringBoot版本真不是越高越好。网上热词里有一条“springboot版本太高”,估计不少人踩过。SpringBoot 3.x要求JDK 17起步,很多同学的机器还停在JDK 8,而且部分老依赖在3.x下的兼容性还没跟上。做毕设求稳,SpringBoot 2.7.x配JDK 1.8是最成熟稳定的组合,网上搜到的资料也最全。
MyBatis-Plus我特别推荐,它几乎解决了单表CRUD的所有重复劳动,内置了分页插件和代码生成器。咱们写选题管理,无非就是根据条件查列表、改状态、统计人数,这些用MyBatis-Plus的Wrapper构造器几行就能搞定,没有必要手写一堆XML映射文件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 功能模块与数据库设计
2.1 角色权限与核心流程
这套系统的权限模型一开始就想得很清楚:三种角色,不允许交叉。登录之后,后端通过JWT令牌里的角色字段决定一个请求能不能被执行。比如/student/selectTopic只有学生能调,教师访问就返回403。
我拆一下核心流程,大家就明白数据库为什么要这么设计了:
- 选题流程:教师“发布选题” -> 学生“申请选题” -> 教师“审核通过或驳回” -> 选题记录状态更新。
- 论文流程:学生“上传论文文件” -> 教师“下载查看、填写评语” -> 状态变为已通过或已打回。
- 答辩流程:管理员“创建答辩分组,设置时间和地点” -> 答辩小组关联学生 -> 教师“录入答辩成绩” -> 系统统计最终得分。
状态机是整个系统的灵魂。比如选题记录就分:待审核、已通过、已驳回、已撤回。论文记录分:待提交、待审核、已通过、已打回。答辩记录分:待答辩、已完成。把这些状态字段设计好,前端页面只需要根据状态值显示不同的按钮和标签,代码写起来会清爽很多。
2.2 表结构设计
一个毕业设计管理系统,核心表大概有七张。我直接列出来,附上关键字段,大家建表时可以当作参考底稿:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| sys_user | id, username, password, role, name, college | 统一用户表,角色区分 |
| teacher_info | id, user_id, title, direction, phone | 教师扩展信息 |
| student_info | id, user_id, student_no, grade, class_name | 学生扩展信息 |
| topic | id, title, description, teacher_id, max_students, selected_num, status | 选题表 |
| selection_record | id, student_id, topic_id, status, apply_time | 选题申请记录 |
| thesis | id, student_id, title, file_path, submit_time, audit_status | 论文信息表 |
| defense_group | id, name, leader_id, defense_time, room, student_ids | 答辩分组表 |
| defense_record | id, student_id, group_id, score, comment | 答辩成绩表 |
这里有一个设计细节值得展开:用户信息为什么要区分sys_user、student_info、teacher_info三张表?因为登录只需要用户名、密码、角色,如果把这些字段和学号、专业、班级、职称全塞在一张表里,后期扩展老师专属字段就会很别扭。拆开之后,sys_user负责认证,student_info和teacher_info负责业务信息,互不干扰。代码层面用MyBatis-Plus联表查询,几行就能把完整信息查出来。
topic表里的max_students和selected_num是建议放弃的字段。选满之后,前端立即禁用申请按钮,后端也要做二次校验。数量控制不能只靠前端,因为并发请求可能同时通过校验,导致超额选入。后端的处理方式是对topic_id加行锁,或者用update topic set selected_num = selected_num + 1 where id = ? and selected_num < max_students这种原子更新语句判断影响行数。
2.3 状态设计:让选题和论文流程可控
状态字段建议直接用int类型,用0、1、2这类数字表示,不需要在数据库里存中文。展示阶段再通过前后端的字典映射转换成文字标签。举个例子,选题记录的状态:0表示待审核,1表示已通过,2表示已驳回,3表示已撤回,4表示教师已释放。
我见过不少同学把“状态”设计成字符串类型,直接存“待审核”“已通过”,看起来直观,但后患无穷。一是无法用数字范围做批量筛选;二是状态表示不统一,中文偶尔带个空格就查不到数据;三是数据库的tinyint比varchar更省空间、查询效率更高。所以状态字段一定要用数字,然后用常量类或枚举类统一管理。
论文表的设计也值得多说一句。文件本身不能直接存MySQL的大字段,我采用的是“服务器本地存储 + 数据库存文件路径”的方案。file_path字段存储相对于上传根目录的路径,比如/files/2024/thesis_1001.pdf。这样数据库轻量,文件也方便备份和迁移。后续如果要做分布式部署,把本地存储换成MinIO或OSS也只需要改一个存储接口的实现。
3. 核心功能实操:从搭建到跑通
3.1 后端工程初始化与核心配置
后端我用Spring Initializr生成一个基础工程,然后手动引入下面这些关键依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
选Sa-Token而不是JWT写一堆拦截器,主要是省事。Sa-Token内置了登录、注销、权限校验、踢人下线这些API,比手写JWT解析和token续期要快很多。当然如果你对JWT的原理更感兴趣,也可以自己写拦截器,但毕设阶段没必要在这里浪费时间。
application.yml的核心配置如下:
yaml复制server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/graduation_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
sa-token:
token-name: satoken
timeout: 86400
token-style: uuid
有两点要特别提醒。第一,serverTimezone=Asia/Shanghai必须加,很多同学数据库连不上就是时区问题导致的报错;第二,logic-delete-field: deleted是MyBatis-Plus的逻辑删除配置,加上之后,删除操作在代码层面会变成update ... set deleted = 1,这样可以防止用户数据被物理物理性删除,答辩时也是加分项。
3.2 登录鉴权与角色校验
登录接口的逻辑非常简单:接收用户名和密码,查到用户后比对密码,通过则调用StpUtil.login(userId)完成登录。密码不是明文存储,要用BCrypt加密。用Sa-Token的SaSecureUtil.md5BySalt配合一个固定的salt做加密也行,但BCrypt更安全,也更容易解释清楚。我建议用BCryptPasswordEncoder,这也是Spring Security推荐的方案。
登录成功之后,每个需要权限的接口只需要一行注解:
java复制@SaCheckRole("teacher")
@PostMapping("/topic/publish")
public R publishTopic(@RequestBody Topic topic) {
return R.ok(topicService.publish(topic));
}
拦截器配置如下,需要放行登录接口和前端静态资源:
java复制@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**")
.excludePathPatterns("/user/login", "/user/register", "/files/**");
}
}
这里有个细节:前端静态资源在开发阶段是走Vue的devServer,与后端端口不一致,所以会涉及跨域。跨域问题的处理我后面单独写一节,但这行excludePathPatterns("/files/**")很关键,不然上传的图片和文件在前端会无法预览。
3.3 选题、答辩排期等核心接口实现
选题接口是系统的高频操作,我把核心逻辑写一下,用MyBatis-Plus的ServiceImpl来处理:
java复制@Transactional(rollbackFor = Exception.class)
public boolean applyTopic(Long studentId, Long topicId) {
Topic topic = topicMapper.selectById(topicId);
if (topic == null || topic.getStatus() != 1) {
throw new BizException("题目不存在或已下架");
}
if (topic.getSelectedNum() >= topic.getMaxStudents()) {
throw new BizException("选题人数已满");
}
LambdaQueryWrapper<SelectionRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SelectionRecord::getStudentId, studentId)
.in(SelectionRecord::getStatus, 0, 1);
Long count = selectionRecordMapper.selectCount(wrapper);
if (count > 0) {
throw new BizException("你已经有一个进行中的选题申请");
}
SelectionRecord record = new SelectionRecord();
record.setStudentId(studentId);
record.setTopicId(topicId);
record.setStatus(0);
selectionRecordMapper.insert(record);
topicMapper.update(null, new LambdaUpdateWrapper<Topic>()
.eq(Topic::getId, topicId)
.setSql("selected_num = selected_num + 1"));
return true;
}
这里有三个细节值得说:第一是@Transactional注解,保证选题记录和人数增加要么同时成功、要么同时失败;第二是人数校验必须放在插入之前,防止超选;第三是对同一个学生做了“进行中申请”的幂等判断,避免重复申请。这些逻辑写清楚,代码在评审老师眼里会很加分。
答辩排期的接口稍微复杂一点,核心是时间冲突判断。我的思路是在创建defense_group时做两层校验:一是这个时间段内该教室是否已被占用,二是同一个学生是否已经被安排到其他答辩组。教室和答辩组的唯一性可以用唯一索引作为兜底,代码里再做一个带for update的查询校验。如果学校场地资源多、时间不紧张,也可以只做前端校验,后端做基础判断,两种方式按实际需求取舍。
3.4 前端Vue页面与接口对接
前端工程我用Vue CLI初始化,然后安装element-ui、axios、vue-router。目录结构上,我把views按角色划分:views/student、views/teacher、views/admin。这样权限相关的路由守卫写起来很直观。
路由守卫的核心思路是:页面跳转前检查本地是否缓存了token,没有就跳转登录页;有token但角色不对,则跳转到对应的首页。代码大致是:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('satoken')
if (to.path === '/login') {
next()
return
}
if (!token) {
next('/login')
return
}
const role = localStorage.getItem('role')
if (to.meta.role && to.meta.role !== role) {
next('/403')
return
}
next()
})
Axios请求封装我也建议做一下。统一的request.js里设置baseURL,让所有请求都走/api前缀,同时加一个请求拦截器在Header里自动带上token,响应拦截器统一处理401未登录和500服务器异常。这样做的好处是,后续如果后端接口地址变了或者要加统一的鉴权处理,只需要改一个文件,不用每个页面都动。
Vue页面开发最常用的组件就是表格、表单、弹窗、上传组件。有一个很实用的经验:所有列表页都做一个“搜索 + 表格 + 分页”的通用结构,用el-form做搜索条件区,el-table展示数据,el-pagination做分页。这套结构写熟了,后面每个功能页面都能套用,效率会高很多。Element的基础组件基本满足需求,不需要再额外引入别的UI库。
3.5 前后端联调与打包部署
开发阶段,Vue的vue.config.js里配一个devServer代理,把/api前缀转发到后端的8080端口:
javascript复制devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
这样前端请求地址写/api/topic/list即可,开发环境下Vue代理转发到后端,生产环境下由Nginx转发。前后端联调时最需要注意的是接口字段命名风格。我统一使用驼峰命名,后端实体也开启MyBatis-Plus的驼峰映射,create_time自动映射成createTime,这样前端拿到的JSON字段是驼峰,写起来顺手很多。
打包部署这一步,我强烈建议把前端打包后的dist目录直接扔进SpringBoot的src/main/resources/static下,让后端一个人搞定静态资源托管。需要做两件事:第一,前端路由使用hash模式,避免访问非根路径时刷新页面404;第二,后端写一个WebMvcConfigurer,把所有非/api和静态资源的请求转发到index.html。这样最终只需要一个jar包就能跑完整个系统,答辩演示时不会因为“前端后端部署步骤太多”而手忙脚乱。
4. 常见问题与排查技巧实录
4.1 跨域问题
前后端分离最经典的问题就是跨域。浏览器拦截的是端口不一致引起的跨域请求,直接表现是前端F12控制台报Access-Control-Allow-Origin错误。解决方案一般有两种:一种在后端写一个CorsFilter全局配置允许跨域,另一种是走Nginx反向代理,把前端请求的/api转发到后端。
我更推荐Nginx代理方案,因为它在生产环境更标准,也避免在代码里写死允许跨域。但如果是本地联调,用SpringBoot的@CrossOrigin注解或者WebMvcConfigurer配置就行。注意,跨域配置不要同时打开多套,否则会重复暴露允许跨域的Header,反而触发浏览器拦截异常。
从项目实战角度来看,我的建议是:开发阶段用@CrossOrigin方便调试,部署阶段用Nginx。两套方案互不冲突,前提是环境切换时别忘记改配置。
4.2 文件上传大小限制
论文文件大多在10MB以内,但有的学校要求提交PDF加附件的压缩包,几十MB也很正常。SpringBoot默认的上传大小是1MB,这时候就需要在application.yml里修改max-file-size和max-request-size。即使配置了50MB,也要注意服务器本身的请求大小限制,Nginx里要同步配置client_max_body_size 50m。
还有一个隐蔽的坑:上传接口如果做了JWT鉴权,而请求走的是multipart/form-data格式,Axios里一定不要主动去设置Content-Type。让浏览器自动生成带boundary的完整请求头,否则后端解析文件时可能拿到空数据。这个坑我调试了快两个小时才想起来,前端代码写的时候一定要小心。
4.3 数据库字段与MySQL关键字冲突
建表的时候,name、type、group这几个字段非常容易踩雷,因为它们可能是MySQL的保留字或常用词。比如我用defense_group做表名,本身没问题,但如果字段也叫group,就会在SQL执行时报语法错误。解决方案是设计表和字段时避开保留字,或者统一给表名加前缀,比如tb_topic、tb_defense_group。
说句实在话,做毕设管理系统,表名和字段名最好起得见名知意,比如topic_id、student_name、defense_time,不要为了省事用id1、name2这种编号式命名。数据库字段清晰,不仅代码写起来舒服,答辩时老师扫一眼表结构也会觉得你设计得很专业。
4.4 答辩时间冲突判断与并发
答辩排期最怕的是同一时间、同一教室被安排了多组人数,或者同一个学生被分配到两个答辩组。简单方案是在defense_group表给defense_time和room加唯一索引,这样数据库层面就保证同一个教室同一时间只能有一组。但这样还不够,因为一个学生只能参加一组答辩,这个约束没法靠唯一索引解决。
我的做法是在创建答辩组时执行一个事务,先SELECT ... FOR UPDATE锁住相关学生记录,然后检查这些学生是否已经存在于其他答辩组,如果存在就抛出异常,否则继续插入。并发量不大的教学管理系统,这种带行锁的校验完全够用,不需要引入分布式锁。只要记住了“先校验后插入”的顺序,数据就不会乱。
4.5 打包部署后页面空白
前端本地跑得好好的,打包丢到后端后打开页面一片空白,这是非常常见的问题。大概率是publicPath配置不正确。vue.config.js里默认的publicPath是/,如果你的前后端是同一个端口部署,我又把dist放到了resources/static下,那路径就得改成相对路径,publicPath: './'。否则静态资源的加载路径会错误,控制台会看到一堆404。
除此之外,还要检查路由模式。如果用了history模式,直接在SpringBoot里访问非根路径会刷新404,所以前面建议用hash模式,否则就要在SpringBoot里写一个forward转发到index.html的兜底控制器。
5. 源码使用与二次开发建议
5.1 从源码启动项目的步骤
如果你手上有对应源码,我按以下步骤操作,基本十分钟就能跑起来:
- 用Navicat新建数据库
graduation_system,导入项目下的sql/graduation_system.sql脚本。 - 修改后端
application.yml里的数据库账号密码,启动SpringBoot主类,确认8080端口正常。 - 进入前端目录,执行
npm install安装依赖;如果下载慢,把npm镜像切换成淘宝源。 - 执行
npm run serve启动开发服务器,浏览器访问http://localhost:8081。
源码拿到手后不要急着跑,先给整个项目做个结构梳理。后端重点看controller、service、mapper三层结构,前端重点看router和api目录。弄清楚一个完整业务流程是怎么从页面到接口再到数据库的,后续改代码才能有的放矢。
5.2 二次开发方向
如果你拿到这套源码想做得更出彩一点,我推荐几个低成本、高回报的扩展点:
- 增加公告通知模块:管理员发布毕业设计相关的通知,学生和教师登录后就能看到。这个模块实现简单,但能体现你对系统完整性的思考。
- 增加成绩导出功能:答辩结束后,管理员一键导出Excel格式的成绩单。这一块可以用EasyExcel或者POI实现,工作量不大但非常实用。
- 增加消息提醒:什么任务待办、审核状态有变化,通过站内信通知用户。可以用WebSocket做实时推送,也可以直接在用户点击刷新时拉取新消息。
- 改成前后端域名分离部署:前端用Nginx托管,后端单独跑jar包,通过
/api反向代理连接。这个改动虽然没有增加功能,但体现了你对生产部署的理解。
我用一个真实的心得做收尾:做这种管理系统,千万不要一上来就追求微服务、Redis、MQ那些“高级”技术。先把SpringBoot + Vue这套基础组合吃透,把业务逻辑和数据库设计做好,项目已经能拿到一个不错的分数。等核心功能稳定了,再根据精力和兴趣挑一两个点做扩展。毕业设计的核心是展示你完整解决一个问题的能力,而不是堆砌技术名词。技术栈不在多,在于你能否把每一层讲清楚、把每一个选择说出理由。这套思路,比源码本身值钱得多。
