又到了一年毕业设计的季节。每年都有不少同学来找我聊选题,问得最多的就是“做什么系统比较简单又能过”“有没有现成的源码参考”。说实话,毕业设计这个事,选系统类的项目,核心不是“代码写得有多花哨”,而是“业务逻辑是否清晰、技术栈是否主流、文档是否完整”。今天分享的这个选题方向——基于Java + Spring Boot的Web毕业设计选题管理系统,就是这类项目里很典型、也很适合本科阶段去实现的一个。它不是单纯做个CRUD,而是把高校毕业设计选题这一整条业务流程搬到了线上:老师发题、学生选题、管理员审核、名额冲突处理、改题申请、数据统计。麻雀虽小,五脏俱全。
这篇内容我会从需求梳理、技术选型、数据库设计、核心模块实现、常见踩坑记录这几个维度,把这个系统怎么从零搭起来、每个环节为什么这么做,完整拆开讲一遍。无论你是正在纠结毕设选题,还是打算拿这套思路去做课设、甚至放到简历上当项目经历,都值得花几分钟把这篇文章看完。
1. 系统到底要解决什么问题:重新梳理一下选题这件事
1.1 选题管理的真实痛点是什么
很多同学没有在学校里跑过这个流程,可能觉得毕业选题不就是老师发个题目,学生挑一个就行了吗,至于做个系统吗?但真实情况比这个复杂得多。
一个中等规模的学院,每年可能有几十位老师参与带毕业设计,每位老师带6到10个学生,意味着要发布几十甚至上百个题目。题目不是一次性发完的,有些老师会分批次发布,有些老师会对题目描述做调整,有些老师带的学生人数有上限,热门题目可能一瞬间就被抢光。如果纯粹靠人工登记、Excel汇总,结果就是:学生不知道还剩什么题目可选,老师不知道谁选了自己的题,教务员要花大量时间做人工协调,经常出现「两个人选了同一个题目」「某个老师被选了12个人但上限只有8个」这类混乱情况。
所以这个系统的第一个核心价值,是把整个选题流程从“线下人工”搬到“线上规范化"。系统的核心业务流程是这样的:
- 管理员创建用户账号、设置选题时间窗口、维护基础数据。
- 教师在选题时间内发布题目,设置可选人数上限。
- 学生在开放时间内浏览题目、提交选题申请。
- 教师审核学生的选题申请,通过或退回。
- 学生被退回后可以重新选择其他题目。
- 管理员全程查看进度,导出统计报表。
这里面最关键的交互关系就是"名额"和"审核状态"。题目有人数上限,学生申请后要等老师审核,审核通过才算真正选上,选满之后该题目自动关闭。这一套状态流转如果只在脑子里想可能会觉得简单,但真正写代码的时候,你会发现状态管理、并发控制、权限校验这些细节才是大头。
1.2 三种角色与权限划分
这个系统涉及的账号角色是三类:管理员、教师、学生。不要小看这个角色划分,它是整个系统权限设计的地基。
- 管理员负责系统配置、账号管理、流程监督、数据统计。
- 教师负责发布题目、审核申请、查看自己的选题名单。
- 学生负责浏览题目、提交申请、查看审核结果。
在Spring Boot里实现这种多角色权限控制,通常用拦截器或者Spring AOP就能解决。核心思路是:用户在登录时把角色信息放进Session,请求进入Controller之前通过拦截器校验当前Session里的角色是否允许访问该接口。
我在实际写这类系统时,比较推荐用一个自定义注解@RequireRole("ADMIN")标注在Controller方法上,拦截器统一做校验。比在每个方法里手写判断要干净得多,代码也更好维护。
2. 技术选型和架构拆解:为什么这套组合最顺手
2.1 Spring Boot + MySQL + MyBatis的搭配考量
技术上选什么,直接决定了你后面写代码是舒服还是难受。
Spring Boot目前已经是Java Web开发的绝对主流。它的自动配置机制极大减少了XML配置量,内嵌Tomcat让你本地开发不用单独装Web容器,打成一个Jar包就能跑。这对毕设来说太重要了——答辩前部署演示,一个Jar包传上去启动就行,不用折腾一整天环境。而且从就业角度,Spring Boot几乎是Java后端岗位的必备技能,写在简历上是加分项。
持久层框架用MyBatis,原因主要有两点。第一,SQL是可控的,你可以手写复杂查询,比如“统计每个题目的申请人数”“关联查询学生选题记录和题目信息”这类需求,写SQL反而比ORM的自动生成更清晰高效。第二,它的学习成本低,入门比JPA快得多,适合在有限时间内完成毕设开发。
MySQL就不用多说了,免费、稳定、网上教程一抓一大把,还不用担心商业授权问题。唯一要提醒的是,本地开发建议直接用MySQL 8.0+,同时把驱动版本和连接串配置对齐,后面我会在踩坑部分专门讲版本不匹配的问题。
前端部分,如果追求效率,不建议上前后端分离。让我说实话,毕设阶段用Thymeleaf模板引擎、配合Bootstrap做页面,一套代码直接搞定前后端,工作量要小三分之一。你非要用Vue + Spring Boot分离开发,也不是不行,但要额外处理跨域、Token鉴权、接口联调这些事,时间成本真的会高出不少。
2.2 分层架构要理清楚
项目结构上,强烈建议严格分层。我在帮人梳理代码时见过太多“所有代码堆在Controller里”的情况,那种代码不是不能跑,是你后期改需求、写文档、答辩讲解的时候会非常痛苦。
推荐的包结构是这样的:
com.example.graduation ├── controller // 接收请求,返回视图或JSON ├── service // 业务逻辑层,处理核心流程 │ └── impl // Service实现类 ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端交互的数据传输对象 ├── config // 配置类(拦截器、WebMvc配置等) ├── common // 通用工具类、统一返回结果封装 └── interceptor // 登录拦截器、权限拦截器Controller只负责参数接收和响应封装,Service层写具体的业务判断,Mapper层跟数据库打交道。这么做的最大好处在于:出问题的时候你知道去哪儿改代码,不需要在三百行的Controller里翻。尤其是答辩时,老师问你“你这个选题名额超了怎么办”,你可以直接说出“在Service层的submitSelection方法里做了容量校验”,一听就是有条理的开发者。
数据库层面,核心表我建议至少设计5张:用户表、角色表、题目表、选题记录表、系统参数表。如果还想要更完整的统计功能,可以再加一张院系列表或者专业表,不过作为毕设来说,前面的5张表已经能覆盖全部主要业务流程了。
3. 数据库表结构怎么设计:这一步千万别偷懒
3.1 基础表字段设计思路
先看用户表,这是三个角色共用的一张表,用role字段区分身份。统一设计的好处是登录逻辑只需要查一次表,不用三张用户表来回切换。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密存储)', real_name VARCHAR(50) COMMENT '真实姓名', role VARCHAR(20) NOT NULL COMMENT '角色:ADMIN/TEACHER/STUDENT', department VARCHAR(100) COMMENT '所属院系', major VARCHAR(100) COMMENT '专业', class_name VARCHAR(100) COMMENT '班级', phone VARCHAR(20) COMMENT '联系电话', email VARCHAR(100) COMMENT '邮箱', status TINYINT DEFAULT 1 COMMENT '状态:1启用 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这里要提醒一句:密码一定要加密存储,千万别用明文。Spring Security的BCryptPasswordEncoder是现成的选择,不需要引入完整Spring Security框架,单独拿BCrypt的工具类来做加密也能用。答辩时老师极大概率会问密码安全的问题,你用了一句“密码通过BCrypt加盐哈希存储”,面试观感完全不一样。
3.2 题目表和选题记录表是核心
题目表的设计要注意三个关键字段:max_students表示这个题目最多能选几人,selected_count可以冗余记录当前已选人数,status表示当前题目的审核状态。为什么要冗余一个selected_count?因为统计列表页要显示“已选/总人数”,每次都用count(*)实时统计当然也行,但列表数据量大时效率会差很多。用冗余字段,每次选题成功时事务里把它加一,查询的时候直接读就行,性能更好。
CREATE TABLE topic ( id BIGINT PRIMARY KEY AUTO_INCREMENT, teacher_id BIGINT NOT NULL COMMENT '发布教师ID', title VARCHAR(200) NOT NULL COMMENT '题目名称', description TEXT COMMENT '题目详细描述', requirements TEXT COMMENT '能力要求', max_students INT DEFAULT 1 COMMENT '可选人数上限', selected_count INT DEFAULT 0 COMMENT '当前已选人数', status TINYINT DEFAULT 0 COMMENT '0待审核 1已发布 2已满员 3已下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (teacher_id) REFERENCES sys_user(id) );选题记录表的核心是状态管理。一条记录从学生提交申请那一刻开始,经历的状态可能是:待审核、已通过、已退回、学生主动取消。注意这里最好有一个is_active字段,因为一个学生可能选过多个题目但最终只保留一个有效的,我们不能把历史记录删掉,要用逻辑状态来管理,而不是物理删除。
CREATE TABLE selection_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, topic_id BIGINT NOT NULL, student_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL COMMENT '冗余教师ID,方便查询', status TINYINT DEFAULT 0 COMMENT '0待审核 1已通过 2已退回 3已取消', apply_reason VARCHAR(500) COMMENT '申请理由', audit_remark VARCHAR(500) COMMENT '审核意见', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME COMMENT '审核时间', UNIQUE KEY uk_student_active (student_id, is_active), FOREIGN KEY (topic_id) REFERENCES topic(id), FOREIGN KEY (student_id) REFERENCES sys_user(id) );这个表设计里有几个细节值得解释。冗余teacher_id字段,是为了方便教师查看“我的题目收到的所有申请”时少一次联表查询。每个学生的有效选题只能有一条,所以建议加上唯一索引的逻辑(实际实现里可以配合is_active字段做部分唯一索引,MySQL 8.0支持函数索引,很好用)。
3.3 状态流转:把业务规则在数据库层面想清楚再写代码
我在给同学们梳理表结构时最常说的一句话是:表和表的关系、状态与状态之间的流转,决定了你的业务逻辑好不好写。如果这一步偷懒,后面写Service会非常痛苦。
举例说明,一个题目的状态变化是什么?
- 教师发布题目时,status = 0(待审核)。
- 管理员审核通过后,status = 1(已发布)。
- 当已选人数等于上限时,自动变成status = 2(已满员)。
- 教师可以主动下架自己的题目,status = 3。
再说选题记录的状态流转:
- 学生提交申请,生成status = 0的记录。
- 教师审核通过,status变为1,同时topic的selected_count加1。
- 教师审核不通过,status变为2,学生可以重新申请其他题目。
- 学生在待审核状态下主动取消,status变为3。
在这个流转过程中,最容易出Bug的地方就是第2步:“审核通过”和“名额加一”必须是一个原子操作。如果名额加一成功但状态更新失败,或者反过来,就会出现一个题目显示还能选但实际已经超员了。所以这里必须加事务,用@Transactional把审核逻辑包起来。
4. 核心功能模块怎么实现:从登录到选题的完整链路
4.1 登录认证与权限拦截
登录逻辑其实很简单,但有几个细节值得写一下。账号密码从前端提交到Controller,Service层根据username查出用户,然后用BCrypt校验密码,通过后往Session里存用户信息。
@Service public class UserServiceImpl implements UserService { @Autowired private SysUserMapper userMapper; @Override public SysUser login(String username, String rawPassword) { SysUser user = userMapper.findByUsername(username); if (user == null) { throw new RuntimeException("用户不存在"); } if (user.getStatus() != 1) { throw new RuntimeException("账号已被禁用,请联系管理员"); } BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); if (!encoder.matches(rawPassword, user.getPassword())) { throw new RuntimeException("密码错误"); } return user; } }权限拦截用HandlerInterceptor实现。这里要说明一个点:Spring Boot里拦截器要做两件事,一个是判断用户是否已经登录,另一个是判断当前用户角色是否能访问这个接口。比较常见的做法是在自定义注解里声明需要的角色,拦截器里通过反射拿到注解信息做判断。
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole == null) { return true; } SysUser loginUser = (SysUser) request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect("/login"); return false; } String[] allowedRoles = requireRole.value(); for (String role : allowedRoles) { if (loginUser.getRole().equals(role)) { return true; } } response.setStatus(403); return false; } }4.2 教师发布选题与管理员审核
教师登录后,在个人中心看到“发布选题”按钮,填写题目、描述、人数上限,提交后存到topic表,状态是待审核。这里注意,教师发布题目的界面上应该默认带出教师ID,而不是让学生自己填教师是谁。
管理员审核列表里会看到待审核的题目,点进去查看详情,然后选择通过或退回。退回时会在页面上触发填写退回意见的逻辑,系统把它存到audit_remark字段里,也就是审核意见,教师可以在自己的题目列表里看到。
这里有个实际开发中的细节:管理员审核通过后,应不应该给教师发通知?如果有站内信模块,可以触发一条消息;如果没有,那么至少题目列表页要用状态标识展示当前审核状态,避免教师反复猜测到底过没过审。我在自己的毕设项目里没有做站内信,而是做了一行状态提示“审核通过,学生可以申请”,虽然不是多高级的功能,但体验上比只改数据库状态强得多。
4.3 学生选题与并发控制
学生进入选题大厅,可以看到所有已经审核通过的题目,分页展示,支持按关键词搜索。点“申请选题”后会弹出确认框,要求学生填写申请理由,然后提交。
后台对应这样一个方法:
@Transactional public void applySelection(Long studentId, Long topicId, String reason) { Topic topic = topicMapper.selectById(topicId); if (topic == null || topic.getStatus() != 1) { throw new RuntimeException("该题目当前不可选"); } if (topic.getSelectedCount() >= topic.getMaxStudents()) { throw new RuntimeException("该题目已选满,不能再申请"); } int currentCount = selectionRecordMapper.countActiveByStudent(studentId); if (currentCount >= 1) { throw new RuntimeException("你已有进行中的选题记录,不能重复申请"); } SelectionRecord record = new SelectionRecord(); record.setTopicId(topicId); record.setStudentId(studentId); record.setTeacherId(topic.getTeacherId()); record.setStatus(0); record.setApplyReason(reason); selectionRecordMapper.insert(record); }逻辑写着简单,但有一个经典坑:“多人同时申请同一个题目,超选了怎么办”。两个学生同时点了申请,都读到selected_count是4,max_students是5,都判断可以申请,插入成功后selected_count变成6,超员了。
解决办法在数据库层面加限制——UPDATE语句带上条件:
int rows = topicMapper.increaseSelectedCountIfAvailable(topicId, maxStudents); if (rows == 0) { throw new RuntimeException("手慢了,名额已经没了"); }对应的SQL是:
UPDATE topic SET selected_count = selected_count + 1 WHERE id = #{topicId} AND selected_count < max_studentsMyBatis的update方法返回受影响的行数,如果affected rows为0,说明条件不成立,名额已经满了。这种CAS风格的更新,在并发场景下比重写业务判断靠谱得多,也是行业里处理秒杀类需求的基本思路。答辩时把这个点讲清楚,老师会认为你真的理解并发。
4.4 教师审核与学生改选
教师的待办列表里展示所有status=0的申请记录。点“通过”时会调一个事务方法:更新申请记录状态为1,同时把topic的selected_count加一。注意,这一步不能直接在Service里写死,要看一下当前题目剩余名额是否大于0,逻辑跟学生申请那道判断是一致的。
学生被退回后,原有记录status变为2,学生可以重新提交新申请。这里又要防一个Bug——学生之前的记录还在“已通过”状态时能不能再次申请?肯定不行。所以学生端每一次提交前,必须校验该学生是否存在status=1的有效记录。有则不允许重复申请,提示先联系老师取消或退还。
4.5 管理员后台与统计导出
说句实话,很多毕设做不出亮点,就是有学生选题,老师审核,然后没下文了。而你只要在管理员后台加一个统计分析模块,项目的完整度会提升一个档次。具体做什么?三个就够:
- 按院系统计选题人数分布。
- 按题目维度统计申请人数和最终选上的人数。
- 导出Excel格式的选题汇总表。
Excel导出用Apache POI,版本注意和项目依赖对齐,我后面会说版本冲突的事。提供一个Controller接口,把查询结果写入Workbook再写到响应流,前端一个按钮调这个接口就能下载。这个功能在答辩演示的时候非常抓眼球,老师会认为你考虑到了实际使用场景,而不只是完成了基本功能。
5. 实操踩坑记录:这些问题我几乎每次都会遇到
5.1 MySQL版本和驱动的兼容性问题
很多新手用MySQL 8.0,但pom里引的驱动还是5.1.x的老版本,导致启动时爆NoClassDefFoundError、或连接成功但查询中文乱码。建议直接用mysql-connector-java 8.0.x版本,同时连接串加上时区参数:
spring: datasource: url: jdbc:mysql://localhost:3306/graduation_design?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver这一条建议,能帮你省掉一大半的数据库连接报错。
5.2 Thymeleaf页面刷新不生效
Spring Boot项目改完前端页面,默认静态资源有缓存,刷新浏览器看不到效果。开发阶段可以在application.yml里关掉缓存:
spring: thymeleaf: cache: false另外,页面里如果用本地js文件,建议加个版本号后缀,比如/js/app.js?v=20240601,不然改了js但浏览器还是旧缓存,排查起来很浪费时间。
5.3 字段命名大小写问题
MySQL在Windows上对表名大小写不敏感,Linux上敏感。开发时用Windows,部署到Linux服务器,如果建表时表名是SysUser,代码里查sys_user,本地没问题,服务器上就报表不存在。所以约定俗成:所有表名、字段名统一用小写加下划线,代码里的实体类属性用驼峰,通过MyBatis的map-underscore-to-camel-case配置自动映射,不要手动在SQL里给每个字段起别名,减少维护成本。
5.4 时间字段的显示格式
LocalDateTime字段直接序列化返回给前端,默认格式是带T的ISO格式,比如2024-06-01T10:30:00,很多浏览器显示出来非常丑。解决办法是加一个jackson全局配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+85.5 事务不生效的问题
在Service里加@Transactional,有时候并不生效。最常见的原因是方法被同类内部调用——private方法调用this.xxx(),Spring的代理不会拦截这种调用。排查方法:检查方法是不是public、入口是不是从外部通过代理对象进来、有没有异常被try-catch吞掉了导致事务无法感知。这个坑如果不提前知道,调试会很痛苦。
6. 交付物和答辩准备:源码之外的事同样重要
很多同学以为把代码写完,毕设就算大功告成,其实不是。毕设的交付物通常不只是源码,还包括数据库脚本、操作说明文档、答辩PPT、演示视频。这个项目的数据库脚本要写清楚创建数据库、建表、初始化管理员账号的语句,让人拿到手5分钟内能把系统跑起来。我在交付时会额外放一份README.md,里面写清环境要求、启动步骤、默认账号密码、常见问题,这样无论是老师看还是未来面试官看,第一印象都会好很多。
答辩的时候要准备一段流畅的演示流程。我的建议是:先进管理员端,展示用户管理、题目审核、统计报表;再切教师端,演示发布题目、审核学生申请;最后切学生端,演示浏览题目、提交申请、查看结果。这条逻辑线把三类角色的核心功能全部串起来,老师看完对你的系统就有了整体认知。
还有一点,代码讲解不要照着Controller从头读到尾,而是挑一两个核心难点讲,比如并发名额控制、状态流转设计、权限拦截。讲清楚为什么这么设计、解决了什么问题,远比把几百行代码念一遍要加分。
最后说点实际的。毕设选题系统这类项目,技术上不算难,但它好就好在业务场景真实、流程完整、可扩展性强。想提升难度,你可以加一个消息通知模块,或者接入WebSocket做选题状态的实时推送;想走数据方向,可以在现有数据基础上做选题热度的可视化管理。但作为本科毕设,先把核心流程做到逻辑严谨、代码整洁、文档完整,就已经是很好的项目了。
我最想提醒你的一件事是:写代码前,一定先把表结构和角色权限梳理清楚。我见过太多同学拿到题目就直接上手写,写了一半发现权限校验补不全、状态流转对不上,然后又开始重构。磨刀不误砍柴工,数据模型是这一类业务系统的地基,地基稳了,后面的代码怎么都好写。