简介:本资源是一套完整的Java Web毕业设计项目——学生毕业设计论文选题系统,面向计算机专业本科生及Java初学者,解决毕业选题流程分散、师生沟通低效、选题管理混乱等实际问题。系统采用B/S架构,涵盖选题展示、学生申请、教师审核、智能分配、在线讨论与进度跟踪六大核心模块,具备完整业务闭环。压缩包共1304个文件,含256个HTML页面、226个CSS样式、215个JavaScript交互脚本、85个JSP动态页面、47个Java后端类及49个编译后Class文件,辅以XML配置、SQL数据库脚本及PNG/GIF等静态资源,整体体积19.05MB,结构清晰、分层明确,便于学习MVC开发模式与前后端协同逻辑。目前已有163人下载学习,可直接部署运行,获取可二次开发的完整源码工程、典型Controller层控制逻辑(如SubjectController、TeacherController等)、基于JSP+Servlet的经典技术栈实践案例,以及贴近高校教务场景的真实业务建模思路。
1. 这不是又一个“管理员后台”:Java学生毕业设计论文选题系统,是解决教务流程断点的最小可行闭环
你见过多少个“毕业设计管理系统”?界面花哨、模块齐全、权限分明,最后部署在老师电脑上跑三天就卡死,学生根本不敢点“提交选题”,导员手动Excel收表、微信群催进度、答辩前夜还在改Word编号——这不是系统,是流程幻觉。而这个基于 Java 的毕业设计论文选题系统,从第一天写需求就锚定一个真实断点:学生找不到合适题目、导师没空逐条审核、教务办无法实时掌握选题饱和度与专业分布。它不搞全生命周期管理,不做答辩打分、文档归档、查重对接;它只做三件事:题目发布(带标签/难度/容量/专业方向)、智能匹配(学生按条件筛+导师按学生画像推)、状态看板(实时显示“计算机系-嵌入式方向-剩余名额≤2”的预警红标)。技术栈极简:Spring Boot 2.7 + MyBatis-Plus + Thymeleaf + H2(开发)/MySQL(生产),无微服务、无Redis、无ES——因为教务系统年访问量不到5000次,加缓存是给自行车装涡轮增压。适合计算机/软件工程专业本科生独立完成毕设、教师快速部署验证流程、或作为课程设计拆解 Spring Boot + 数据库事务 + 前后端分离的最小原型。如果你正被“功能太多做不完”“数据库设计绕晕”“前后端联调到崩溃”折磨,这个项目就是你的后悔药。
2. 从零搭起骨架:Spring Boot + MyBatis-Plus 工程初始化与核心表结构设计逻辑
2.1 为什么选 MyBatis-Plus 而非 JPA?三个血泪经验换来的决定
很多同学一上来就选 Spring Data JPA,觉得注解少、自动CRUD香。但在这个系统里,我们很快会遇到三个硬骨头:
- 题目容量动态扣减:学生选题成功后,“剩余名额”字段必须原子性减1,且不能为负。JPA 的
@Version解决不了并发超选,而 MyBatis-Plus 的UpdateWrapper可直接写setSql("remain_num = remain_num - 1")配合gt("remain_num", 0)条件,一行SQL搞定乐观锁; - 多条件模糊聚合查询:学生筛选时要同时按“专业方向(多选)”“难度等级(范围)”“关键词(标题+描述)”“是否已满”组合过滤。JPA Criteria API 写起来像解微分方程,而 MyBatis-Plus 的
QueryWrapper链式调用in("major", majors).between("level", 1, 3).like("title", key).eq("is_full", false)直观到能当伪代码读; - 导出Excel需自定义字段顺序与别名:教务办要导出“学号、姓名、题目、导师、状态”五列,但数据库表里学生信息分散在
student和selection两张表。MyBatis-Plus 的@TableName(autoResultMap = true)+@TableField(select = false)+ 自定义@SelectSQL,比 JPA 的@SqlResultSetMapping少写60%模板代码。
提示:本项目未使用 Lombok,所有 Entity 手写 getter/setter/toString——不是复古,是方便调试时打断点看字段值,避免 Lombok 的字节码注入让 IDE 变成黑匣子。
2.2 四张核心表的设计动机与字段取舍
系统仅用4张表实现闭环,拒绝“为设计而设计”:
| 表名 | 字段精简清单(关键字段) | 设计理由 |
|---|---|---|
topic(题目表) | id,title,description,major(varchar,存“计算机科学与技术,人工智能”逗号分隔),level(tinyint,1-5),capacity(int),remain_num(int),teacher_id,status(0-草稿/1-发布/2-关闭) | 不建major_dict关联表!教务专业方向三年一变,硬关联导致每次新增方向都要改库、改代码、改前端下拉。用逗号分隔字符串,查询时用 MySQL 的FIND_IN_SET('人工智能', major),牺牲一点索引性能,换来教务老师自己改Excel就能更新方向列表。 |
student(学生表) | id,stu_no(唯一),name,major,class_name,phone | 不存密码字段!学生登录走学校统一身份认证(CAS/LDAP),本系统只存学号映射关系。若需本地测试,密码字段留空,登录接口直接返回 success。 |
teacher(导师表) | id,tea_no,name,title(职称),email,max_topic(最多指导题目数) | max_topic是硬约束字段,非统计值。学生选题时,系统先查该导师topic表中teacher_id对应的题目总数,再对比max_topic,超限则禁止发布新题——这是防导师被塞爆的核心闸门。 |
selection(选题记录表) | id,stu_id,topic_id,status(0-待审核/1-已通过/2-已拒绝/3-已撤回),apply_time,audit_time | status用数字而非字符串,避免中文乱码风险;apply_time和audit_time分开存,方便统计“平均审核时长”,教务办真要这个数据。 |
2.3 初始化脚本:H2 内存库快速验证,MySQL 生产环境无缝切换
开发阶段用 H2 内存库,启动即建表、无需安装数据库。application-dev.yml中配置:
spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver h2: console: enabled: true path: /h2-console对应schema-h2.sql初始化语句(仅贴关键建表部分):
-- 题目表 CREATE TABLE topic ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT, major VARCHAR(200), -- 存逗号分隔的专业方向 level TINYINT DEFAULT 3, capacity INT DEFAULT 5, remain_num INT DEFAULT 5, teacher_id BIGINT NOT NULL, status TINYINT DEFAULT 1 -- 0草稿,1发布,2关闭 ); -- 选题记录表(带联合唯一索引防重复提交) CREATE TABLE selection ( id BIGINT AUTO_INCREMENT PRIMARY KEY, stu_id BIGINT NOT NULL, topic_id BIGINT NOT NULL, status TINYINT DEFAULT 0, apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME NULL, UNIQUE KEY uk_stu_topic (stu_id, topic_id) );逻辑说明:
selection表的uk_stu_topic联合唯一索引是防学生重复点击“提交选题”的最后一道防线。MyBatis-Plus 插入时若违反此约束,捕获DuplicateKeyException后返回友好提示“您已提交过该题目”,而非抛500错误。生产切换 MySQL 时,只需修改application-prod.yml的url和driver-class-name,其余 SQL 完全兼容——H2 的CURRENT_TIMESTAMP与 MySQL 语法一致,TINYINT类型也通用。
3. 核心业务落地:题目发布、学生选题、导师审核的三段式事务控制
3.1 题目发布:带容量校验与状态机的原子操作
导师发布题目不是简单 INSERT,而是状态流转:从“草稿” → “发布”,且需校验两个硬条件:
- 导师当前已发布的题目数 <
teacher.max_topic; capacity≥ 1(没人会发“容纳0人”的题目,但程序必须拦住这种脏数据)。
对应 Service 层代码:
@Transactional(rollbackFor = Exception.class) public Result<String> publishTopic(Topic topic, Long teacherId) { // 1. 查询导师信息并校验最大题目数 Teacher teacher = teacherMapper.selectById(teacherId); if (teacher == null) { return Result.fail("导师不存在"); } // 查询该导师已发布的题目数量(status=1) int publishedCount = topicMapper.selectCount( new QueryWrapper<Topic>().eq("teacher_id", teacherId).eq("status", 1) ); if (publishedCount >= teacher.getMaxTopic()) { return Result.fail("已达最大指导题目数:" + teacher.getMaxTopic()); } // 2. 校验容量 if (topic.getCapacity() < 1) { return Result.fail("题目容量不能小于1"); } topic.setTeacherId(teacherId); topic.setStatus(1); // 发布状态 topic.setRemainNum(topic.getCapacity()); // 剩余名额初始=总容量 boolean saveSuccess = topicService.save(topic); return saveSuccess ? Result.success("发布成功") : Result.fail("发布失败"); }参数说明:
@Transactional是必须的——若校验通过后save()失败,整个事务回滚,避免出现“已校验通过但题目未存入”的中间态。Result是自定义返回类,含code(200/500)、msg、data三字段,前端统一处理,不暴露异常堆栈。
3.2 学生选题:乐观锁扣减名额 + 状态机防重复操作
学生点击“选择此题目”触发的核心逻辑,必须解决两个并发问题:
- 超选问题:A、B 两名学生同时选最后一个名额,不能都成功;
- 重复提交:学生手抖连点两次“提交”,不能生成两条记录。
解决方案:数据库层面用remain_num > 0条件更新 + 应用层用selection表联合唯一索引兜底。
@Transactional(rollbackFor = Exception.class) public Result<String> selectTopic(Long stuId, Long topicId) { // 1. 先查题目是否存在且可选(status=1, remain_num>0) Topic topic = topicMapper.selectOne( new QueryWrapper<Topic>() .eq("id", topicId) .eq("status", 1) .gt("remain_num", 0) ); if (topic == null) { return Result.fail("题目不可选:已关闭或名额已满"); } // 2. 乐观锁扣减名额:UPDATE topic SET remain_num = remain_num - 1 // WHERE id = ? AND remain_num > 0,返回影响行数 UpdateWrapper<Topic> updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("id", topicId) .gt("remain_num", 0) .setSql("remain_num = remain_num - 1"); int updateRows = topicMapper.update(null, updateWrapper); if (updateRows == 0) { return Result.fail("抱歉,提交时名额已被抢完"); } // 3. 插入选题记录(联合唯一索引防重复) Selection selection = new Selection(); selection.setStuId(stuId); selection.setTopicId(topicId); selection.setStatus(0); // 待审核 try { selectionMapper.insert(selection); } catch (DuplicateKeyException e) { return Result.fail("您已提交过该题目,请勿重复操作"); } return Result.success("选题提交成功,等待导师审核"); }逻辑说明:
updateRows == 0是关键判断点。MyBatis-Plus 的update()方法返回实际影响的行数,若为0,说明WHERE条件不满足(即remain_num在查询后被其他事务减到了0),此时立即返回失败,不执行后续插入。这比在插入后查remain_num再回滚更高效,也避免了“先插后删”的脏数据风险。
3.3 导师审核:状态流转与通知的轻量实现
导师审核不走消息队列,用最朴素的“更新+查”:
@Transactional(rollbackFor = Exception.class) public Result<String> auditSelection(Long selectionId, Integer status, String remark) { Selection selection = selectionMapper.selectById(selectionId); if (selection == null || selection.getStatus() != 0) { return Result.fail("记录不存在或非待审核状态"); } // 更新选题记录状态与备注 selection.setStatus(status); // 1通过/2拒绝 selection.setRemark(remark); selection.setAuditTime(new Date()); selectionMapper.updateById(selection); // 若通过,同步更新题目剩余名额(此处为保险,实际选题时已扣减,但防数据不一致) if (status == 1) { Topic topic = topicMapper.selectById(selection.getTopicId()); if (topic != null && topic.getRemainNum() > 0) { topic.setRemainNum(topic.getRemainNum() - 1); topicMapper.updateById(topic); } } return Result.success("审核完成"); }注意:
auditSelection方法末尾的“二次扣减”是防御性编程。理论上selectTopic已扣减,但若因网络超时等原因导致学生端未收到成功响应,学生可能重试,此时selection表因唯一索引拦截,但topic.remain_num可能被多扣一次。所以审核通过时再校验一次remain_num > 0并扣减,确保最终一致性。这不是最佳实践,但对年访问量<5000的教务系统,比引入 RocketMQ 更可靠。
4. 避坑指南:那些让90%新手在第三天就放弃的五个真实陷阱
4.1 现象:学生选题后,topic.remain_num变成负数
原因:并发场景下,多个线程同时执行selectTopic,updateWrapper.gt("remain_num", 0)条件在数据库快照中仍为真,但 UPDATE 语句执行时remain_num已被其他事务修改。MySQL 的UPDATE ... SET x = x - 1 WHERE x > 0本身是原子的,但 MyBatis-Plus 的update()方法返回值判断逻辑有误——若你用了topicMapper.update(topic, updateWrapper)(传入实体),它会尝试更新所有字段,导致remain_num被覆盖为旧值。
解决:严格使用topicMapper.update(null, updateWrapper),第二个参数为null,表示只按UpdateWrapper中的setSql执行,不涉及实体字段映射。并在updateWrapper中明确指定setSql("remain_num = remain_num - 1"),杜绝字段覆盖。
4.2 现象:导师在 H2 控制台看到题目,但 Spring Boot 启动后查不到
原因:H2 内存库默认每个连接是独立的内存实例。spring.h2.console.enabled=true开启的 H2 Console 是一个独立的 JDBC 连接,而 Spring Boot 应用通过DataSource获取的是另一个连接,两者内存不共享。你在 Console 里建的表,应用连不上。
解决:在application-dev.yml的 H2 URL 后追加;DB_CLOSE_DELAY=-1(已给出),并确保DB_CLOSE_ON_EXIT=FALSE。更重要的是,所有表结构和初始化数据必须写在schema-h2.sql中,由 Spring Boot 启动时自动执行,而非人工在 Console 里敲。Console 仅用于调试查询,不用于建表。
4.3 现象:major字段存逗号分隔字符串,FIND_IN_SET查询在 MySQL 8.0+ 报错
原因:MySQL 8.0.17+ 默认开启了only_full_group_by模式,而FIND_IN_SET('AI', major)在 GROUP BY 查询中会被认为是非确定性函数,触发 SQL 错误。虽然本系统无复杂 GROUP BY,但若后续加统计报表,可能踩坑。
解决:两种方案任选其一:
- 方案A(推荐):在
application-prod.yml的 MySQL URL 后加?sessionVariables=sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION',显式关闭only_full_group_by; - 方案B:改用 JSON 字段类型(MySQL 5.7+),
major定义为JSON,存["计算机科学与技术","人工智能"],查询用JSON_CONTAINS(major, '"人工智能"'),更规范但增加学习成本。
4.4 现象:Thymeleaf 模板中th:each="topic : ${topics}"渲染空白,控制台无报错
原因:Controller 返回ModelAndView时,model.addAttribute("topics", topics)的topics是List<Topic>,但Topic实体类缺少无参构造函数或 getter 方法(尤其getMajor()返回String,但字段名是major,Lombok 未启用时易漏写)。Thymeleaf 反射调用 getter 失败,静默跳过渲染。
解决:检查Topic.java是否包含完整 getter:
public class Topic { private Long id; private String title; private String major; // 注意字段名是 major,不是 majorList // 必须有 public 无参构造 public Topic() {} // 必须有 public getter,且方法名严格匹配:getMajor() 对应 major 字段 public String getMajor() { return major; } }提示:IDEA 中右键
Generate...→Getter,勾选所有字段,不要手写拼错方法名。
4.5 现象:部署到 Linux 服务器后,application-prod.yml配置的 MySQL 地址连不上,本地 Windows 正常
原因:Linux 服务器防火墙(如ufw或firewalld)默认阻止 3306 端口;或 MySQL 用户权限未授权从%(任意主机)访问,只允许localhost。
解决:
- 检查 MySQL 用户权限:
SELECT host, user FROM mysql.user WHERE user='your_db_user';,若host是localhost,执行GRANT ALL PRIVILEGES ON your_db.* TO 'your_db_user'@'%' IDENTIFIED BY 'password'; FLUSH PRIVILEGES;; - 开放防火墙端口:
sudo ufw allow 3306或sudo firewall-cmd --permanent --add-port=3306/tcp && sudo firewall-cmd --reload; - 确保 MySQL 配置文件
/etc/mysql/mysql.conf.d/mysqld.cnf中bind-address为0.0.0.0(非127.0.0.1)。
5. 看板与导出:用原生 SQL 实现教务办真正需要的实时数据透视
5.1 一张 SQL 解决“各专业题目饱和度”看板
教务办最常问:“计算机系还剩几个题目?物联网工程的题目谁在带?”——这不是要炫酷图表,是要一行SQL导出Excel。我们在TopicMapper.xml中写原生 SQL:
<select id="selectMajorSaturation" resultType="map"> SELECT t.major as major_name, COUNT(*) as total_topic, SUM(CASE WHEN t.remain_num = 0 THEN 1 ELSE 0 END) as full_topic, ROUND(AVG(t.remain_num), 1) as avg_remain, GROUP_CONCAT(DISTINCT te.name SEPARATOR '; ') as teachers FROM topic t LEFT JOIN teacher te ON t.teacher_id = te.id WHERE t.status = 1 GROUP BY t.major ORDER BY total_topic DESC </select>逻辑说明:
GROUP_CONCAT将同一专业的所有导师姓名拼成张三; 李四; 王五,教务办一眼看清“人工智能方向”由哪几位老师支撑;AVG(t.remain_num)计算平均剩余名额,比单纯总数更有决策价值——若某专业总数多但平均只剩0.5,说明题目太难或描述不清,需干预。此 SQL 在 H2 和 MySQL 下均可用,H2 的GROUP_CONCAT需开启SET DATABASE_TO_UPPERCASE FALSE;(已在schema-h2.sql中配置)。
5.2 学生选题导出:规避 POI 依赖,用 CSV 流式生成
不引入 Apache POI(jar 包大、易OOM),用最简text/csv响应:
@GetMapping("/export-selection") public void exportSelection(HttpServletResponse response) throws IOException { response.setContentType("text/csv;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=selection_export.csv"); List<Map<String, Object>> data = selectionMapper.selectSelectionExport(); // 调用上述 SQL try (PrintWriter writer = response.getWriter()) { // 写表头 writer.println("学号,姓名,题目,导师,状态,申请时间,审核时间"); // 写数据行 for (Map<String, Object> row : data) { String stuNo = Objects.toString(row.get("stu_no"), ""); String stuName = Objects.toString(row.get("stu_name"), ""); String topicTitle = Objects.toString(row.get("topic_title"), ""); String teaName = Objects.toString(row.get("tea_name"), ""); String status = "0".equals(row.get("status").toString()) ? "待审核" : "1".equals(row.get("status").toString()) ? "已通过" : "已拒绝"; String applyTime = Objects.toString(row.get("apply_time"), ""); String auditTime = Objects.toString(row.get("audit_time"), ""); // CSV转义:字段含逗号、换行、双引号时,用双引号包裹,内部双引号转义为"" String line = String.format("\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"", escapeCsv(stuNo), escapeCsv(stuName), escapeCsv(topicTitle), escapeCsv(teaName), escapeCsv(status), escapeCsv(applyTime), escapeCsv(auditTime)); writer.println(line); } } } private String escapeCsv(String s) { if (s == null) return ""; return s.replace("\"", "\"\"") // 双引号转义 .replace("\n", " ") // 换行转空格 .replace("\r", " "); // 回车转空格 }参数说明:
escapeCsv方法是核心。CSV 规范要求:若字段含逗号、双引号、换行符,必须用双引号包裹,且字段内双引号需转义为两个双引号("→"")。此实现兼容 Excel、WPS、Numbers 打开,且内存占用恒定 O(1),10万条记录也秒出。
5.3 教务预警:用定时任务扫描“超期未审核”题目
教务办要求“导师48小时内必须审核”,我们用 Spring Boot@Scheduled实现:
@Component public class AuditWarningTask { @Autowired private SelectionMapper selectionMapper; @Scheduled(cron = "0 0 9 * * ?") // 每天上午9点执行 public void warnOverdueSelections() { // 查找 status=0(待审核)且 apply_time 超过48小时的记录 LocalDateTime twoDaysAgo = LocalDateTime.now().minusHours(48); List<Selection> overdueList = selectionMapper.selectList( new QueryWrapper<Selection>() .eq("status", 0) .lt("apply_time", Date.from(twoDaysAgo.atZone(ZoneId.systemDefault()).toInstant())) ); if (!overdueList.isEmpty()) { // 发送邮件/短信逻辑(此处简化为打印日志) System.out.println("【预警】发现 " + overdueList.size() + " 条超期未审核选题:"); overdueList.forEach(s -> { System.out.println(" 学号:" + s.getStuId() + ",题目ID:" + s.getTopicId()); }); } } }注意:
@Scheduled默认单线程执行,不会并发触发。若需高可用,可结合 Quartz 或 XXL-JOB,但对教务系统,每天一次扫描足够。LocalDateTime.now().minusHours(48)使用系统时区,避免new Date(System.currentTimeMillis() - 48*60*60*1000)的时区陷阱。
6. 从那以后我每次交付毕设系统,都强制走一遍这三步验证
6.1 第一步:用 Postman 模拟最极端的并发选题
不靠前端点,直接用 Postman 发 100 个并发请求,验证remain_num是否精准扣减:
- 创建集合,设置
POST /api/topic/select请求,Body 为{"stuId":1001,"topicId":1}; - Runner 中设 100 次迭代、10 线程(模拟10人同时抢)、延迟0ms;
- 断言脚本检查响应:
// 检查是否全部返回 success 或 “名额已满” const jsonData = pm.response.json(); pm.test("Response is success or full", function () { pm.expect(jsonData.code).to.be.oneOf([200, 500]); pm.expect(jsonData.msg).to.be.oneOf(["选题提交成功,等待导师审核", "抱歉,提交时名额已被抢完"]); });- 最后查数据库:
SELECT remain_num FROM topic WHERE id = 1;—— 结果必须等于初始值 - min(100, 初始容量)。若出现负数,立刻回看updateWrapper写法。
6.2 第二步:用 H2 Console 手动篡改数据,测试状态机健壮性
故意把selection.status改成99(非法值),然后调用auditSelection接口:
- 预期行为:返回
fail("记录不存在或非待审核状态"),不抛500; - 若返回500,说明 Controller 层未捕获
IllegalArgumentException,需在@ExceptionHandler中补充:
@ExceptionHandler(IllegalArgumentException.class) public Result<String> handleIllegalArg(IllegalArgumentException e) { return Result.fail("参数错误:" + e.getMessage()); }提示:状态机的边界值测试比功能测试更重要。
status字段在selection表中是TINYINT,但代码里用Integer接收,若数据库存了128(超出TINYINT有符号范围),Java 会解析为-128,导致状态判断失效。所以auditSelection方法开头必须加if (status != 1 && status != 2) { return fail("状态非法"); }。
6.3 第三步:用真实教务数据跑通全流程,记录三组关键耗时
拿上一届真实数据(200学生、50题目、15导师)导入,执行:
- 题目发布耗时:导师批量发布50题,记录从点击“提交”到页面刷新显示“发布成功”的时间(应 < 2s);
- 选题峰值耗时:模拟200学生在1分钟内完成选题,查
selection表COUNT(*)是否为200,且topic.remain_num总和是否等于50*初始容量 - 200; - 导出耗时:执行
/export-selection,用浏览器开发者工具看 Network 的Finish时间(应 < 3s,CSV 无渲染压力)。
如果任一耗时 > 5s,立即检查:
- MySQL 是否开启慢查询日志(
slow_query_log=ON); selection表是否建了INDEX idx_stu_topic (stu_id, topic_id);topic表的status和remain_num字段是否有联合索引(INDEX idx_status_remain (status, remain_num))。
从那以后我每次交付毕设系统,都强制走一遍这三步验证——不是为了炫技,是怕学生在答辩前夜发现“选题没生效”,而我的解释是“数据库事务没提交”。希望帮到你。
本文还有配套的精品资源,点击获取