1. 项目概述:为什么学生选题系统值得做一个毕设
先说个现象。每年毕业季,指导老师邮箱里塞满"老师我选这个题目行不行"的邮件,教务处还在用Excel表格来回传选题名单,学生想换题得跑三趟办公室。做毕业设计选题系统的念头,基本就是从这个混乱场景里来的。作为Java方向的毕设选题,它既不大到失控,又能把Spring Boot的核心知识点串起来——用户认证、权限区分、数据表关联、事务回滚、定时任务,全都能落在这一个项目里。
不少同学拿到这个题目第一反应是"这不就是个CRUD吗"。表面看确实如此,学生选题目、老师审核题目、管理员管理数据,本质上就是增删改查。但真做起来你会发现,一个能稳定跑完整个选题周期、还要应付几百人同时提交的系统,远没有想象中简单。选题排重(热门题目不能无限制选人)、师生双选流程、志愿冲突处理、截至时间控制、管理员后台统计,这些才是系统的核心难点,也是答辩时最容易被评委追问的地方。
这个项目更适合谁参考?如果你是Java基础已经学完、但还没独立做过完整Web项目的同学,它刚好卡在"会写单机Demo"和"能交付完整系统"之间。如果你已经在准备毕设答辩、需要一个有真实业务深度和技术亮点的系统,这套选题系统的双选流程设计也能给你不少答辩素材。
另外强调一句,网上源码满天飞,但能不能把代码讲清楚、能不能面对评委的追问不露怯,才是毕设拿高分的关键。这也是我写这篇文章的初衷,不只要给一份能跑的代码,更要让你明白每一行关键代码背后的设计逻辑和取舍过程。
2. 整体设计思路与核心功能拆解
2.1 选题系统的真实业务流程是什么
要设计一个系统,先要把现实世界的业务流程摸清楚。高校毕设选题通常分为三个阶段:教师申报题目、学生选择题目、系部审核分配,个别学校还会在正式选题之前安排两轮预选,用来避免热门题目全部挤在第一轮被抢完。
按这个流程拆解出来的业务需求是:
- 教师端:题目申报、题目修改、选题学生列表查看、过程审核(选择或拒绝学生)、题目命中情况统计。
- 学生端:浏览题目库、按专业或方向筛选题目、填报志愿(有的学校支持多个志愿)、查看选题结果、被拒后重新选题。
- 管理员端(通常是教学秘书角色):账号管理、题目终审与发布、选题轮次控制、系统参数配置(选题起止时间、每人志愿数、教师指导人数上限)、数据导出与统计。
- 公共模块:登录认证、角色权限校验、个人信息维护、密码修改。
这里有一个容易忽略但非常关键的需求层级问题。如果只盯住"学生能选、老师能审",那做出来的系统可能连基本教学场景都撑不住。比如有些学校的选题流程是"教师发布题目、学生提交申请、教师确认后该生选题成功",而另外一些学校是"学生先报两个志愿、系统按规则统一分配"。这两种流程差别很大,对应的表结构设计完全不同。
在动手写代码之前,一定要把这个问题问清楚。做毕设的时候答辩老师问一句"你的系统适配哪种选题模式",你不能只回答"我的系统就是让老师和学生互选的"。最稳妥的做法是支持"直选模式"和"志愿模式"两种类型,管理员在后台切换,这也是我最终代码里的实现方式。
2.2 技术栈选型的理由:为什么是Spring Boot 2.7
技术选型是每个Spring Boot项目都绕不开的第一步,这里说说我的具体考量。
我选的组合是:Spring Boot 2.7.x + MyBatis-Plus + MySQL 8.0 + Spring Security + Redis(可选),前端采用Thymeleaf服务端渲染加一套开源Admin模板,同时预留了RESTful API接口供Vue前端接入。为什么要这么组合,理由如下。
Spring Boot版本选了2.7而不是3.x。原因很实在:很多学校的机房和实验室环境还在用JDK 8,Spring Boot 3.0强制要求JDK 17,一旦老师要求现场演示时用的是老环境,项目直接跑不起来就很尴尬。Spring Boot 2.7是2.x时代的最后一个稳定版本,既支持JDK 8,又能兼容大量老教程的写法,对毕设场景最友好。
持久层框架我选择了MyBatis-Plus而不是纯MyBatis或JPA。MyBatis-Plus提供分页插件、代码生成器、条件构造器,能把重复的XML映射量减少一大半。比如按题目名称模糊查询、按教师姓名联合查询学生列表,这种业务在MyBatis-Plus里直接用LambdaQueryWrapper就能链式写出来,不写一行SQL,开发效率提升非常明显。答辩的时候你要能解释清楚为什么用它——减少样板代码、内置分页、和Spring Boot集成成本低,这比什么都说不出来强太多。
权限认证是一个值得展开的点,也建议你在答辩时准备好原理表述。我使用了Spring Security + JWT的模式,管理员登录后返回一个带过期时间的Token,前端每个请求都携带这个Token。这里有一个踩坑经验:很多初学者试图自己写拦截器来做权限校验,实现起来确实不难,但Spring Security能帮你做密码加密(BCrypt,不可逆加密)、Session管理、CSRF防护(跨站请求伪造防护)这些安全细节。如果完全自己写,这些安全处理需要考虑的部分会特别多,项目的安全性和完成度反而不如直接使用成熟框架好。
Redis在这套系统里属于可选项。它主要用来缓存验证码、缓存热门题目列表、实现选题高峰期的分布式锁。如果你想让项目有技术亮点,引入Redis是个不错的决策。但注意前提——不要在代码里为了用Redis而用Redis。举例来说,学生选题时同一道题可能被多个学生同时选中,并发量超过单机数据库承受时,需要防止"超选"。Redis分布式锁(用SETNX命令实现互斥)就可以在一个节点处理这个场景,比纯数据库锁更好解释也更好展示。
前端我用了Thymeleaf服务端渲染,而不是现在流行的前后端分离Vue方案。原因同样很现实——毕设答辩现场最怕的就是"前端环境突然起不来"。本地开发调试需要启动两个服务的情况,不确定因素多一些。Thymeleaf模板把页面和后端打成同一个Jar包,一个命令就全部启动。而且对于有大量表格和表单的管理后台类系统,服务端渲染其实更直接。如果你想展示前后端分离的能力,我会在项目里额外准备一个/api前缀的RESTful接口层并配上Postman测试用例,这不仅能证明你具备接口开发能力,也不会影响整体系统的稳定性。
2.3 表结构设计:七张核心表和它们的关联逻辑
这个项目的表结构不多,但每次关联查询都有业务含义。我最终设计并落地的表如下:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| sys_user | 用户表(学生、教师、管理员统一存储) | username、password、role_id、real_name、student_no或teacher_no、department_id |
| sys_role | 角色表 | role_name、role_code(STUDENT/TEACHER/ADMIN) |
| department | 院系列表 | dept_name、dept_code |
| topic | 题目表 | topic_name、topic_desc、teacher_id、type(源码研发类/调研类/工程应用类)、status(草稿/待审核/已发布/已截止)、quota(限选人数)、selected_count |
| selection_record | 选题记录表 | student_id、topic_id、status(志愿1/志愿2/已录取/已拒绝/已退选)、select_time、audit_time |
| notice | 公告表 | title、content、publish_time、publisher_id |
| sys_config | 系统配置表 | config_key、config_value(如选择截止时间、每人最大志愿数、是否开启预选) |
你需要把握的核心知识点是seletion_record这个关联表的设计思想。一个学生可以选择多个题目(作为不同志愿),一个题目可以被多个学生选择(多个志愿进入池子,但最终只录取设定人数),这种多对多关系不能直接往topic表里加一个student_id字段了事,必须拆出中间表。
同时要注意,这张关联表是理解业务规则的关键,帮我理清了整个流程:选题时间段内,学生将对不同题目提交志愿申请;教师登录后有优先审核权,可以直接录取某位学生,也可以拒绝;到了分配截止日,系统可以根据预先设定的规则做自动化分配——按志愿顺序和教师指导名额上限来排。
还有一个很多初学者容易犯的错误:把角色字段直接写死在用户表里,比如is_admin=1表示管理员、is_teacher=0表示不是老师。等后面需求一变,想增加一个"教研室主任"或"教学督导"角色时就只能改表结构。正确的做法是使用RBAC模型(用户-角色-权限模型),用户表里存role_id,角色表里对应权限字符串(用逗号分隔存permissions字段即可,不必教条地上五张表那套权限设计)。
3. 核心功能实现与关键代码解读
3.1 登录认证与权限拦截
前面提到了用Spring Security + JWT,这里给出登录接口的具体实现思路。
登录接口本身不难,核心就三步:从请求中拿到用户名、密码,调用AuthenticationManager校验,校验通过后生成Token返回给前端。代码如下:
@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtTokenProvider tokenProvider; @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest loginRequest) { // 1. 使用Spring Security的AuthenticationManager执行认证 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证通过后生成JWT令牌 String token = tokenProvider.generateToken(authentication); // 3. 查询用户信息并返回 User user = userService.getUserByUsername(loginRequest.getUsername()); return Result.success(new LoginResponse(token, user)); } }这段代码你看着简单,但每一行背后都有可以展开讲的原理。第一行AuthenticationManager是Spring Security认证流程的总入口,它在内部会调用UserDetailsService来加载用户、再用PasswordEncoder比对密码。默认情况下它用DaoAuthenticationProvider做认证,所以你需要实现一个UserDetailsServiceImpl,从数据库里查询用户后把用户名、密码、角色构造为UserDetails对象交给框架。
这里要提醒你不要在Service层把用户密码以明文形式保存。注册或者创建账号时使用BCryptPasswordEncoder加密。我测试过,BCrypt加密同一个密码每次得到的密文都是不同的,因为它内部嵌入了随机盐值。在答辩时,密码加密设计是一个容易被延伸提问的点,把这个原理说明白会很加分。
JWT的过滤器链配置同样值得认真理解:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/**", "/css/**", "/js/**", "/images/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .antMatchers("/api/teacher/**").hasAnyRole("TEACHER", "ADMIN") .antMatchers("/api/student/**").hasAnyRole("STUDENT", "TEACHER", "ADMIN") .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }需要额外说明的是,.hasRole("ADMIN")这种写法会自动加上ROLE_前缀去匹配数据库里存的角色标识,如果你数据库里存的是role_code=ADMIN,那UserDetails里设置的authority就要写成"ROLE_ADMIN",两者需要严格对应。这个对应关系即使是有经验的开发者偶尔也会搞混,如果在调试时出现403错误,首先要核查一下这个地方的写法。
JWT过滤器本身做的是Token解析工作:从Authorization请求头里拿到"Bearer xxx"字符串,去掉Bearer前缀后用密钥解析出用户名,再拿着用户名去加载用户信息并放入SecurityContext。这样下游接口的@PreAuthorize("hasRole('STUDENT')")注解就能识别出当前请求人的身份了。
3.2 选题核心业务与排重事务处理
选题功能是整个系统最核心的部分,需要处理几个容易出错的场景。这里给出核心Service代码:
@Transactional(rollbackFor = Exception.class) @Override public SelectionResult selectTopic(Long studentId, Long topicId, Integer priority) { // 1. 校验时间段是否开放 if (!configService.isSelectPeriodOpen()) { return SelectionResult.fail("不在选题时间段内"); } // 2. 获取题目信息并锁定行(悲观锁) Topic topic = topicMapper.selectByIdForUpdate(topicId); if (topic == null) { return SelectionResult.fail("题目不存在"); } // 3. 校验题目已审核发布 if (!"PUBLISHED".equals(topic.getStatus())) { return SelectionResult.fail("题目未发布"); } // 4. 校验该题剩余名额 if (topic.getSelectedCount() >= topic.getQuota()) { return SelectionResult.fail("该题已选满"); } // 5. 校验学生是否已经在该题上投过志愿 int existsCount = selectionRecordMapper.countByStudentAndTopic(studentId, topicId); if (existsCount > 0) { return SelectionResult.fail("您已选择过该题目,不能重复提交"); } // 6. 校验学生志愿数量是否超过上限 int studentSelectionCount = selectionRecordMapper.countByStudent(studentId); if (studentSelectionCount >= configService.getMaxSelectionCount()) { return SelectionResult.fail("您的志愿数已达上限"); } // 7. 插入选题记录,题目已选人数+1 SelectionRecord record = new SelectionRecord(); record.setStudentId(studentId); record.setTopicId(topicId); record.setPriority(priority); record.setStatus("WAITING"); selectionRecordMapper.insert(record); topic.setSelectedCount(topic.getSelectedCount() + 1); topicMapper.updateById(topic); return SelectionResult.success("选题申请提交成功"); }这个方法涉及的几个技术点你得心里有数。
第一,@Transactional注解保证了事务一致性。如果插入记录成功、后面的题目人数加一失败,事务会回滚到方法执行前的状态,不会出现"申请记录存在但人数没加"或者反过来的人数不一致问题。Spring的事务默认只在RuntimeException抛出时回滚,为了保险起见我特意写了rollbackFor = Exception.class,这个细节和它背后的原因值得在答辩时展示你的严谨性。
第二,第2步使用了selectByIdForUpdate行级锁。如果有两个学生同时点击同一道题的"选题"按钮,数据库层面的事务隔离机制会保证只有一个事务能先锁住这一行并执行到提交,另一个事务会阻塞等待。不加这把锁的话,在两个并发请求同时执行到"if selectedCount >= quota"时都可能通过校验,最后导致超选。把这把悲观锁配合先读后写的操作在事务里使用,是最容易理解和讲解的并发安全方案。
第三,第5步和第6步的学生重复选同一题与志愿数上限,是常规的业务规则校验。你也许会想,为什么这两步不放在数据库层面做唯一约束?其实可以在selection_record表上加一个(student_id, topic_id)联合唯一索引来防止同一名学生重复选同一题,这也是双保险思路——业务层校验保证提示友好,数据库唯一索引兜底保证极端并发情况下的数据安全。
志愿模式和直选模式在代码里的区分点在后面的分配逻辑。如果系统配置为"直选模式",教师看到待审核志愿后可以直接点击录取,学生状态变为"已录取",其他志愿自动退回并释放名额。如果系统配置为"志愿模式",则到了分配时间由定时任务统一执行分配——按优先级排序、同一志愿内先到先得、教师名额满了就顺延下一个志愿。这一段分配算法是整个项目里最复杂的部分,也是一条非常有分量的答辩考点。
3.3 教师端功能:题目审核与学生志愿处理
教师端的功能实现相对直接,但有几个设计细节值得展开。
教师在题目管理页面可以新增、编辑、删除自己的题目。这里要注意关联数据的处理方式:如果一道题目已经有人选了,删除操作是直接执行还是逻辑删除?实际操作时,直接物理删除会导致selection_record里出现悬空的topic_id,后续统计报表会有数据错误。稳妥的办法是逻辑删除——给topic表加一个deleted字段,默认值为0,执行删除时更新为1,查询时自动过滤。同时,前端根据题目是否有关联的选择记录来禁用删除按钮,提示教师"该题目已有学生申报,不可删除,可改为已截止状态",从产品层面为这一处理做了兜底。
教师在志愿处理页面看到某个题目的申报学生列表时,可以使用表格展示下列信息:学生姓名、学号、专业方向、申报时间、学生当前的志愿排序、是否第一篇论文已开题等。点击录取后,前端弹出确认框并提示"该操作将占用名额,是否确认?",后端在Service层执行记录状态更新时,需要一并把学生的其他待审核志愿批量退回。
@Transactional(rollbackFor = Exception.class) public void approveStudent(Long recordId) { SelectionRecord record = selectionRecordMapper.selectById(recordId); if (record == null || !"WAITING".equals(record.getStatus())) { throw new BizException("该申请已处理,请勿重复操作"); } // 更新当前记录为已录取 record.setStatus("APPROVED"); record.setAuditTime(LocalDateTime.now()); selectionRecordMapper.updateById(record); // 退回该学生其他待审核志愿 QueryWrapper<SelectionRecord> wrapper = new QueryWrapper<>(); wrapper.eq("student_id", record.getStudentId()); wrapper.eq("status", "WAITING"); wrapper.ne("id", recordId); List<SelectionRecord> waitRecords = selectionRecordMapper.selectList(wrapper); for (SelectionRecord waitRecord : waitRecords) { waitRecord.setStatus("REJECTED_AUTO"); selectionRecordMapper.updateById(waitRecord); // 释放对应题目的占用名额 Topic topic = topicMapper.selectById(waitRecord.getTopicId()); if (topic.getSelectedCount() > 0) { topic.setSelectedCount(topic.getSelectedCount() - 1); topicMapper.updateById(topic); } } }这一段里处理"退回其他志愿时同步释放名额",很多初学者最容易漏掉。选题系统里名额的增减逻辑必须全链路一致,如果题目被某学生以第二志愿占了一个名额、学生又被第一志愿录取了,这时候第二志愿占用的名额没有释放,那么这个第二志愿对应的题目名额会一直被无效占用,最终导致后面的学生无法再选、实际招生人数不到上限。
3.4 学生端功能:浏览题目、填报志愿与结果查询
学生端的核心页面有三个:题目浏览列表、我的志愿、选题结果。
题目浏览列表通常以大表格形式呈现,包含题目名称、题目类型、指导教师姓名、职称、限选人数、已选人数、所属院系、题目简介等。前端提供筛选器,按院系、题目类型、状态条件组合过滤。我建议使用MyBatis-Plus的分页插件,加上QueryWrapper动态拼接条件:
public PageResult<TopicVO> queryPublishedTopics(TopicQuery query, Page page) { LambdaQueryWrapper<Topic> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Topic::getStatus, "PUBLISHED"); wrapper.like(StringUtils.hasText(query.getKeyword()), Topic::getTopicName, query.getKeyword()); wrapper.eq(StringUtils.hasText(query.getType()), Topic::getType, query.getType()); wrapper.eq(query.getDeptId() != null, Topic::getDeptId, query.getDeptId()); wrapper.orderByDesc(Topic::getPublishTime); // 关联查询教师姓名、已选人数等,分页返回 IPage<Topic> result = topicMapper.selectPage(page, wrapper); return convertToPageResult(result); }选择题目时,系统在服务端校验通过后弹出成功提示,并把学生的这道选题记录放进"我的志愿"列表,可在截止之前调整志愿顺序或取消选择。志愿顺序调整也要在事务中实现——把同一位学生现有的所有志愿记录按照新的顺序重排,由于priority字段的唯一性约束是(student_id, priority),排列时可以先把这个学生的记录统一清除再批量插入,或者先查询出来统一更新,这里注意到批量更新会对唯一约束冲突有影响,建议过渡性换成临时值操作,这也是实际操作中一个隐蔽的坑。
3.5 管理员后台:配置、审核与统计
管理员(通常由教学秘书或教研室主任扮演)的页面需要涵盖下面几个能力。
配置管理是管理员后台中处于最上游的功能,控制时间窗口和基础参数。我设计了一张sys_config表,把"选题开始时间""选题截止时间""每人最多志愿数""教师指导上限""当前选题轮次""是否开启预选"等参数都存储在键值对中。管理端有专门的配置页面做可视化编辑,后端提供一个ConfigService统一读取配置。因为部分参数如选题开启状态会在学生选题时频繁读取,每次实时查询数据库效率不高,可以把它放到Redis的String结构里并设置10分钟过期,这就是用缓存优化热数据的经典场景。
题目审核是管理员在正式发布前需要做的把关工作。教师申报题目后状态是"PENDING",管理员可以逐条预览,不合格的题目填上审核意见后驳回,驳回后教师端会收到站内通知,重新编辑后再次提交。题目只有在审核通过之后才对所有学生可见,避免出现"教师还没写完就开放给学生看"的问题。
统计导出是评委老师比较关心的部分,推荐你实现。管理员选择某个院系或某位教师,点击导出Excel,系统把该范围下的题目数、已选人数、录取名单等数据整理成列表。实现技术上,最朴素的做法是使用Apache POI直接生成xlsx文件写入HttpServletResponse流,浏览器拿到后弹"下载",如果你不想引入重依赖,也可以生成CSV格式,Excel一样能打开,代码量小很多。
4. 实操过程:从零搭建到跑通全流程
4.1 初始化项目结构与数据库脚本
在这里提供一个经过我完整走通的项目初始化方案,你可以直接按步骤操作。
创建Spring Boot项目时,建议使用Spring Initializr生成基础骨架,Group填com.example,Artifact填graduation-selection,语言选Java,打包方式选Jar,Java版本选8,依赖勾选Spring Web、Spring Security、MyBatis Framework、MySQL Driver和Lombok。生成完成后把pom.xml里MyBatis依赖替换为mybatis-plus-boot-starter,记得确认版本号与Spring Boot版本匹配,我实测用的是mybatis-plus-boot-starter 3.5.3.1。
数据库初始化脚本建议放到项目根目录的sql文件夹下,统一加上创建库、建表、插入基础数据的语句。你要注意凡是角色、管理员初始账号这类基础数据,都应当通过SQL脚本插入,而不是在代码里写死。初始数据可以设置admin/123456(这里只是演示环境,正式你们自己部署时务必提醒改密)、teacher/wang、student/20210001这类简洁账号,方便演示。
-- 建库 CREATE DATABASE IF NOT EXISTS graduation_selection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE graduation_selection; -- 用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号(学号/工号)', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', real_name VARCHAR(50) NOT NULL, role_id BIGINT NOT NULL, dept_id BIGINT, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, enabled TINYINT DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 题目表 CREATE TABLE topic ( id BIGINT PRIMARY KEY AUTO_INCREMENT, topic_name VARCHAR(100) NOT NULL, topic_desc TEXT, type VARCHAR(30) COMMENT '题目类型', teacher_id BIGINT NOT NULL, dept_id BIGINT, quota INT DEFAULT 1 COMMENT '限选人数', selected_count INT DEFAULT 0 COMMENT '已选人数', status VARCHAR(20) DEFAULT 'PENDING' COMMENT 'PENDING/APPROVED/PUBLISHED/DISABLED', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记,1为已删除', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, publish_time DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表';注意建表时所有表和字段都不要加反引号(MySQL客户端的转义符),因为有些版本的MySQL执行加反引号脚本时会报语法错误。字段注释用COMMENT写到表定义里,这样各种数据库工具查看表结构时会直观得多。核心推荐的关键一步在于:写好项目后,把SQL脚本反复用MySQL 8.0重新来源执行确认没有错误,这对后续演示时减少一次无用功有直接帮助。
4.2 环境准备的小提示
开发调试时你不需要准备太多东西。JDK建议用8或11。Maven用3.6+,IDEA用2022版或更新版本,启动项目前先确认Maven已经拉完所有依赖——很多"爆红错误"都源于依赖没有下载完整,具体排查方法见后面的问题表。
MySQL建议用Docker直接本地起一个实例:
docker run --name mysql8 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -d mysql:8.0如果你的电脑内存不够,也可以直接安装MySQL Community Server。启动时注意字符集问题,如果乱码了优先检查连接URL是否加入了useUnicode=true&characterEncoding=utf8,以及表和库是否都是utf8mb4编码。
4.3 联调中的完整演示路径
系统跑通之后,要把一遍完整的演示流程做到能闭着眼睛操作,可以按下面的路径来完成:
- 使用管理员账号登录系统,进入后台页面,配置开放式选题的参数(例如开启选题申请、每人最多提交3个志愿、选题截止时间设为一周后)。
- 使用教师账号登录系统,维护填报个人申报题目:"基于Spring Boot的××订餐系统设计""基于深度学习的××识别方法研究"等,将题目提交审核。
- 切回管理员账号审核教师申报的题目,将其状态标记为已审核,系统自动放行让学生可见。
- 使用学生账号登录系统,在公开页面浏览已审核通过的题目,通过院系筛选项先筛选一台题目,点击"选择"按钮,填写选题理由与联系方式后提交志愿。
- 退回教师账号进入"学生申请"模块就能看到学生提交的选题申请并进行批准操作,经由"批准"按钮确认后学生的其他志愿同步退回落选。
- 使用统计报表功能导出选中题目的学生信息名单,作为留存的附件记录。
演示时这六步跑完,"这个系统完整支撑了毕业设计的全流程"的感觉就出来了。
5. 远程调试、部署与个性化修改
5.1 远程调试毕业设计项目
实际调试中有一个高频痛点,特别是学生习惯性完成了系里部署以后,仍然希望在自己电脑上开着IDEA进行断点排查问题、走查逻辑修改代码。这里的核心手段就是JVM的远程调试端口。
原理一句话:在服务器侧启动JVM时加一条特殊参数,令JVM打开一个调试Socket接口,本地的IDEA再以该接口为跳板,把本地调试器的指令发送给服务器端运行的进程,完成断点暂停、变量查看的效果。
实际操作中,在服务器上以类似下面的方式启动Jar包:
java -jar -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 graduation-selection.jar然后在自己电脑的IDEA中创建一个Remote JVM Debug运行配置,Host填服务器IP,Port填5005,配好后点击Debug按钮即可连接。要注意两点:第一,本地代码和服务器运行的代码必须保持完全一致,否则断点会断不住或者行号错乱;第二,远程调试模式下代码执行速度会有一定下降,遇到并发场景的问题时不推荐开着调试做压测。
由于很多学校不允许学生在公网服务器上开放5005这种冷门端口(安全考虑),一个替代方案是使用IDEA 2020.1以上版本集成支持的"远程开发"功能,或者用内网穿透工具把服务器端口映射到本地访问。但如果你做毕设项目时服务器和本地都在校园网内,直接开端口断点调试最省事。
5.2 部署时的配置文件处理
Spring Boot项目的部署核心就是那个"打包后能直接执行"的Jar包。
mvn clean package -DskipTests执行成功后target目录下的Jar包就可以部署。部署环境里的配置与本地不同,推荐使用Spring Boot的多环境配置:application-dev.yml对应本地环境,application-prod.yml对应服务器环境,启动时用--spring.profiles.active=prod指定生产环境配置文件。生产配置里数据库连接字符串、Redis地址、JWT密钥等参数应通过环境变量注入,而不是直接硬编码到文件里再推送到代码仓库——这个细节在校生可能体会不深,但在工作里不过制度要求,就是运维兄弟上门找谈话的水平。
Jar包启动后,建议使用systemd配置成服务方式管理,这样系统重启后服务能自动拉起。同时nginx反向代理绑定前端域名与80端口,后端服务在内部监听8080即可。
5.3 个性化定制:如何把通用代码变成"我的项目"
网上同类的选题系统源码非常多,如果你想避免答辩时撞车,需要从以下两个维度做出差异化。
维度一是业务功能差异。大多数示例文章里只会实现最朴素的"学生选、老师审",而你可以额外加上"导师学生双选匹配度计算"、"同一专业方向志愿热度统计"、"基于学位论文选题方向的查重预检提示"等有亮点的功能。举个例子,在选题申请时采集学生的前三个志愿方向并按"题目内容与所学方向匹配度"的算法筛出低匹配志愿给予风险提示,这个功能体量小、纯代码逻辑实现、不需要额外硬件,在答辩演示时是很好的差异化功能点。
维度二是界面呈现的差异化。前端模板如果用了Bootstra的AdminLTE或NowUI风格,通过配色、Logo名称和流程向导式页面、进度条展示选题状态,会让系统看起来比默认模板更"完整"。把高校的Logo占位和标准毕业设计页面注意事项替换进去,视觉上就会很贴近实际交付项目的观感。
6. 常用问题排查、答辩防坑与复盘经验
6.1 环境与运行常见问题速查
按项目执行顺序整理以下场景中我实际遇到且概率较高的报错,你用最小代价做排查:
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 项目启动报"Failed to configure a DataSource" | 数据库连接信息未配置或配置错误 | 检查application.yml里url/username/password,确认MySQL服务已启动,且库确实已通过sql脚本初始化 |
| 查询中文显示乱码 | 数据库字符集或连接参数不对 | 建库语句用utf8mb4,连接URL加useUnicode=true&characterEncoding=utf8 |
| Spring Security放行接口仍被拦截(403) | CSRF配置或角色前缀不匹配 | 查看SecurityConfig中的permitAll路径和角色名是否一致,Role匹配记得加"ROLE_"前缀 |
| @Autowired注入为null(空指针) | Service没有交给Spring管理 | 检查类上是否标注@Service/@Component,并检查是否在启动类默认扫描路径下 |
| 前端修改了页面但运行时不生效 | 浏览器缓存了静态资源 | IDEA中clean后再重启,浏览器强制刷新,或模板配置中加入cache=false |
| 时间报错差8小时 | 数据库和JVM时区不一致 | MySQL连接URL加serverTimezone=Asia/Shanghai,代码中使用LocalDateTime避免日期格式化时默认时区问题 |
| Redis连接失败(若启用) | Redis没启动或密码不对 | 本地启动redis-server,生产环境检查spring.redis.host配置 |
6.2 答辩常见追问:这些问题必须准备
评委看完一个毕设项目,通常会从以下几个方向提问,我把高频问题按追问概率从高到低列出:
"为什么选择Spring Boot?Spring Boot与Spring MVC的关系是什么?" 这个问题一定回答得出来。Spring Boot基于"约定优于配置"思想,内嵌服务器、自动装配,Spring MVC是它的Web层框架。试着用一个例子展开说明:SpringBootStarterWeb这个依赖会自动装配DispatcherServlet和相关的Json转换器等,而不需要像传统SSM那样手动配置web.xml。
"MyBatis-Plus和MyBatis的区别在哪里?为什么不用JPA?" 回答要点:MyBatis-Plus对MyBatis做了增强但未做改变,提供通用Mapper与条件构造器,写单表操作时免去重复SQL;JPA自动建表和对象映射很强,但多表关联复杂查询不直观。这是"以SQL为中心的半自动框架"和"以对象为中心的ORM"两种风格之争,实际做教务系统这类SQL复杂场景时,前者更可控。
"如果大量学生同一时间开始选题,你的系统如何保证不超选?" 这个问题我之前在第3.2节已经用悲观锁解释过。建议答辩时现场把代码中selectByIdForUpdate这一行指给评委看,再补充说明这里用得是数据库行锁而不是表锁,避免把并发性能拉低——单热点行上锁本身量级可控,MySQL InnoDB范围条件大于等于时命中唯一索引即可按行加锁,反之可能会变成范围锁。
"如果教师录取某个学生后,又发现自己名额满了,如何回退?" 从功能上说需要一个"取消录取"操作,把记录状态回滚,释放名额,同时把排队中的候选学生重新激活。在设计时最好把这种逆向操作考虑进去,并预留相关的页面入口。
"项目里的复杂实时统计报表是怎么实现的?" 统计报表不需要实时查询几张表再在Java内存里算。管理员筛选系部大名单后,前端异步向后端拉取榜单数据。后端返回可指定范围的数据,使用MyBatis的XML文件写group by聚合查询,把结果用VO对象返回,既快又方便用ECharts绘制直方图。
这些问题只要提前过一遍,即使代码里某些细节记不那么清楚,也能够讲出正确的思路。
6.3 开发过程中最值得复盘的几个坑
复盘这个项目从零到完成的经历,有几个坑是绝大多数学生真正写代码时都会踩到的,必须单独拎出来说。
志愿排序功能改动时的唯一约束冲突是个隐蔽的坑。前面我提到过,如果要调整同一个学生在selection_record表里的优先级顺序时,如果分两条update语句分别把志愿3改成志愿1、再改原志愿1为志愿3,过程中会有瞬时唯一约束冲突。建议的做法是先把所有志愿priority改成负数(比如-1、-2),再重新设置最终顺序,两条SQL在一个事务里执行完。
事务自调用失效是另一个很常见的问题。如果ProjectServiceImpl类里的公开方法A没有事务注解、但它调用了同一个类里另一个有@Transactional的方法B,方法B的事务是生效不了的,因为Spring的事务是由代理对象决定的,而内部通过this调用并不会经过代理。要规避这一点,只能把B单独放到一个Service里注入进去,或者让A也标注事务注解——我在代码导学视频里几乎每次都会强调这个点,它可以节省很多看日志排错的时间。
还有一个属于细节层面的问题就是逻辑删除和唯一约束的矛盾。如果你给topic表加了deleted字段做逻辑删除,而题目列表又有业务唯一约束,比如同一教师下题目名不能重复,则需要在唯一约束里把deleted字段一起作为条件。否则当你第一次逻辑删除一个旧题目、再添加同名的题时,数据库会因已删除的同名题目仍占用唯一索引而无法插入。简单的解决方案是把逻辑删除做成mybatis-plus的逻辑删除字段deleted(配置:logic-delete-value: 1,logic-not-delete-value: 0),再在性能允许的范围内把唯一约束做成(deleted, teacher_id, topic_name),将deleted为1的所占范围虚拟唯一化,就能做到与逻辑删除兼容的唯一方案。
最后,也是最核心的一点,依然要回到"能讲清楚"这个角度。你要能做到不看代码也能把整条业务链路口述出来:谁在什么时候可以做什么、这一步操作改了什么状态改了哪几张表、为什么这样设计。为了备答辩,强烈建议把核心流程图手绘一张,放到论文第3章系统设计里去。这会逼你梳理清楚自己写的核心逻辑,而且图上画的任何一条路径,你都应该能在代码里找到对应实现——这是答辩现场最稳健的状态。