简介这份资源是面向高校计算机相关专业学生与指导教师的毕业设计选题平台完整项目源码采用前后端分离架构后端以Java开发前端结合Vue、JavaScript、HTML与CSS实现响应式界面并融入人工智能算法优化选题推荐解决传统课题管理中信息分散、选题效率低的问题。压缩包共102个文件约2.73MB包含30个Java源文件、12个Vue组件、12个JavaScript脚本以及SQL建表脚本、XML配置、properties配置、图标字体与说明文档等覆盖课题发布、浏览、选择、审核与管理等核心模块并配有用户权限管理与操作日志功能。目前已有45人学习下载。读者可据此获得一套结构清晰、模块化拆分的完整毕设方案便于理解选题推荐逻辑、权限控制与数据库设计思路也可作为二次开发与课程设计的参考模板。1. 从一张 Excel 抢题表说起毕业设计选题平台到底要解决什么每年十月到十一月教研室群里最热闹的事就是发一张 Excel 选题表几十个老师把题目填进去几百个学生同时打开抢。结果往往是有人看到的是旧版本有人填完保存覆盖了别人的选择还有人干脆把整行删了。基于 Web 的毕业设计选题平台本质就是把这张会打架的 Excel 换成一套有账号、有状态、有并发控制的在线系统。它要解决的核心问题只有三个题目谁能发、学生怎么选、选完怎么锁。适合做这个方向的人是手里有 Java 或 Spring Boot 基础、想找一个业务闭环完整、能讲清楚数据一致性的本科或初级开发者。它不追求高精尖算法但对状态流转和并发写入的要求比很多 CRUD 系统都真实。2. 选题平台的技术选型为什么我最终押注 Spring Boot Vue 前后端分离2.1 从 JSP 到前后端分离选型不是跟风很多同学一上来就问用 JSP 还是 Thymeleaf我的建议是直接放弃服务端渲染页面。选题平台有大量实时状态变化某个题目还剩几个名额、当前是第几轮选题、学生是否已经确认。如果每次操作都整页刷新体验差不说还容易在刷新间隙产生重复提交。前后端分离之后前端只负责渲染和交互后端只暴露 JSON 接口状态判断全部收口到服务端这是控制并发的前提。后端我一般选 Spring Boot原因很实际生态成熟遇到问题搜得到内置 Tomcat打包成 jar 就能跑配合 MyBatis-Plus 或 Spring Data JPA单表增删改查几乎不用写 SQL。前端选 Vue 3 加 Element Plus表单、表格、弹窗组件开箱即用省掉大量样式调试时间。数据库用 MySQL 8事务和行锁机制足够支撑选题这种中等并发场景。缓存层可选 Redis用来存题目余量和分布式锁但如果只是单机部署MySQL 的悲观锁也能扛住。这里要提醒一句不要为了显得高级而引入微服务。选题平台的用户规模通常在一个学院几百人单体应用加一层缓存完全够用。拆成服务反而让事务边界变模糊选题扣减名额这种强一致操作会变得很难写。2.2 环境搭建与项目骨架生成下面是我常用的初始化步骤基于 Spring Boot 3.x 和 Vue 3。先建后端骨架用 Spring Initializr 生成依赖勾选 Spring Web、MySQL Driver、MyBatis-Plus、Lombok。# 用 curl 调 Spring Initializr 生成后端骨架 curl https://start.spring.io/starter.zip \ -d typemaven-project \ -d languagejava \ -d bootVersion3.2.0 \ -d groupIdcom.example \ -d artifactIdselection-platform \ -d nameselection-platform \ -d dependenciesweb,mysql,mybatis-plus,lombok \ -o selection-platform.zip unzip selection-platform.zip -d selection-platform cd selection-platform这段命令的作用是拉取一个标准的 Maven 项目结构dependencies里web提供 REST 能力mysql是驱动mybatis-plus简化持久层lombok减少 getter/setter 样板代码。生成后检查pom.xml里 Spring Boot 版本和 Java 版本是否匹配你本地的 JDK我一般用 JDK 17。前端用 Vite 创建 Vue 项目npm create vitelatest selection-frontend -- --template vue cd selection-frontend npm install npm install element-plus axios vue-router piniaelement-plus提供 UI 组件axios负责请求vue-router管路由pinia管登录态和全局状态。装完之后在main.js里注册 Element Plus 和 Pinia这一步网上模板很多不展开。2.3 数据库表设计四张核心表撑起整个选题流程选题平台的表不用多但字段要想清楚。我一般建四张核心表用户表、题目表、选题记录表、轮次配置表。表名关键字段说明sys_userid, username, password, role, class_namerole 区分学生/教师/管理员topicid, title, teacher_id, max_count, current_count, status, round_idstatus 控制题目是否可选selectionid, student_id, topic_id, round_id, create_time, status唯一索引防重复选round_configid, round_name, start_time, end_time, status控制选题轮次开关selection表上必须建(student_id, round_id)的唯一索引这是防止一个学生在一轮里选多个题目的最后一道防线。topic表的current_count和max_count用来控制名额扣减时要用行锁或乐观锁版本号。CREATE TABLE selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, topic_id BIGINT NOT NULL, round_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT 1已选 2已确认 3已退选, UNIQUE KEY uk_student_round (student_id, round_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;唯一索引uk_student_round保证同一个学生在同一轮只能有一条记录。如果业务允许退选后重选就把status纳入索引或改用逻辑删除但那样唯一约束会失效需要额外处理。我一般建议退选时直接删除记录保持唯一索引有效。3. 选题核心逻辑并发扣名额、状态流转与接口实现3.1 选题接口的并发问题为什么先查后改一定会翻车最朴素的写法是先select查题目余量判断current_count max_count然后update加一。这个写法在两个人同时点的时候必然出问题因为两个线程可能都查到余量是 1然后都执行更新最后current_count变成 3 或者覆盖成 2。这就是典型的竞态条件。解决办法有三种悲观锁、乐观锁、Redis 原子操作。单机 MySQL 场景我推荐乐观锁实现简单且不会长时间持锁。在topic表加一个version字段更新时带上版本号。UPDATE topic SET current_count current_count 1, version version 1 WHERE id #{topicId} AND current_count max_count AND version #{version};这条 SQL 把「判断余量」和「扣减」合并成一次原子操作current_count max_count放在WHERE里返回影响行数为 0 就说明没抢到。配合唯一索引兜底即使扣减成功但插入selection失败事务回滚也会把名额还回去。3.2 用 Spring 事务包住选题全流程选题不是一条 SQL 的事它包含校验轮次是否开放、校验学生是否已选、扣减题目名额、插入选题记录。这四步必须在一个事务里。Service public class SelectionService { Autowired private TopicMapper topicMapper; Autowired private SelectionMapper selectionMapper; Autowired private RoundConfigMapper roundConfigMapper; Transactional(rollbackFor Exception.class) public void selectTopic(Long studentId, Long topicId, Long roundId) { // 1. 校验轮次是否开放 RoundConfig round roundConfigMapper.selectById(roundId); if (round null || round.getStatus() ! 1) { throw new BizException(当前轮次未开放); } // 2. 校验学生本轮是否已选 Long count selectionMapper.countByStudentAndRound(studentId, roundId); if (count 0) { throw new BizException(你已在本轮选过题目); } // 3. 扣减名额乐观锁 Topic topic topicMapper.selectById(topicId); int affected topicMapper.decreaseCount(topicId, topic.getVersion()); if (affected 0) { throw new BizException(手慢了题目名额已满); } // 4. 插入选题记录 Selection selection new Selection(); selection.setStudentId(studentId); selection.setTopicId(topicId); selection.setRoundId(roundId); selection.setStatus(1); selectionMapper.insert(selection); } }Transactional的rollbackFor Exception.class很关键默认只回滚运行时异常业务异常如果是受检异常不会回滚。第 3 步的decreaseCount对应上面那条带version的 SQL返回 0 直接抛异常事务回滚前面的查询不影响数据。第 4 步插入如果撞上唯一索引也会抛异常回滚名额自动还回去。参数说明studentId从登录态里取不要从前端传否则可以伪造topicId和roundId前端传但服务端要校验题目是否属于该轮次。3.3 前端调用与状态刷新前端选课按钮点击后要禁用按钮防止重复提交请求成功后再刷新题目列表。async function handleSelect(topicId) { if (submitting.value) return submitting.value true try { await axios.post(/api/selection/select, { topicId, roundId: currentRound.value.id }) ElMessage.success(选题成功) await loadTopics() // 重新拉取题目列表刷新余量 } catch (e) { ElMessage.error(e.response?.data?.message || 选题失败) } finally { submitting.value false } }submitting是一个响应式布尔值绑定到按钮的disabled属性。注意这里只防住了同一个页面内的重复点击跨页面或刷新后的重复提交还是要靠服务端唯一索引。前端刷新列表是为了让余量显示准确但真正的权威数据永远以服务端为准。4. 避坑与排查选题平台上线后最容易炸的五个地方4.1 现象两个学生同时选最后一个名额都显示成功原因扣减名额的 SQL 没加current_count max_count条件或者用了先查后改的写法。解决把条件写进UPDATE的WHERE里用影响行数判断是否成功返回 0 就抛异常。同时检查事务是否生效Transactional注解的方法必须是 public且不能自调用。4.2 现象学生退选后重新选提示「已选过」原因退选时只改了status字段没有删除记录唯一索引(student_id, round_id)仍然生效。解决退选直接物理删除记录或者把唯一索引改成(student_id, round_id, status)并约定退选状态不参与约束。我一般选物理删除逻辑最简单。4.3 现象题目列表余量显示不对实际还有名额却显示已满原因前端缓存了旧数据或者后端查询用了读写分离从库主从延迟导致读到旧值。解决选题成功后强制刷新列表如果用了从库扣减后的查询走主库。单机部署不会有这个问题但一旦上云就要注意。4.4 现象轮次时间到了但学生还能选原因轮次状态判断只在前端做了后端没校验或者校验用的是服务器时间但时区不对。解决后端每次选题都查round_config的start_time和end_time用LocalDateTime.now()比较确保服务器时区是Asia/Shanghai。前端隐藏按钮只是体验优化不是安全边界。4.5 现象教师删除题目后已选该题的学生记录变成孤儿原因删除题目时没检查是否已有学生选择或者用了物理删除但没处理关联记录。解决删除前先查selection表是否有该题目的有效记录有则禁止删除或改为下架状态。外键约束可以加但很多团队为了性能不加那就必须在业务层兜住。5. 进阶技巧用 Redis 预扣名额把并发扛到更高当学院规模大到上千人同时抢MySQL 的行锁会成为瓶颈。这时候可以在 Redis 里预扣名额用DECR原子操作判断余量成功后再异步落库。核心思路是把topic:count:{topicId}作为计数器初始值等于max_count每次选题先DECR返回值小于 0 就说明满了直接拒绝大于等于 0 再走数据库流程。数据库流程失败时要把 Redis 计数INCR回去。public boolean preDeduct(Long topicId) { String key topic:count: topicId; Long remain redisTemplate.opsForValue().decrement(key); if (remain null || remain 0) { // 恢复计数避免负数持续累积 redisTemplate.opsForValue().increment(key); return false; } return true; }decrement是原子操作多个线程同时执行不会超卖。但要注意Redis 计数和数据库计数可能不一致比如数据库插入失败后补偿没执行。所以最终一致性要靠定时任务对账每隔几分钟用数据库的current_count重置 Redis 计数。这个方案适合对性能要求高、能接受最终一致的场景如果要求强一致还是老老实实用 MySQL 乐观锁。另一个技巧是给选题接口加限流用 Guava RateLimiter 或 Sentinel防止脚本刷接口。我一般按学生维度限流每个学生每秒最多一次选题请求超出直接返回「操作过于频繁」。这个阈值可以根据实际并发调整但一定要有否则一个学生写个循环就能把名额全锁住。最后说个我自己的习惯每次上线前用 JMeter 开 200 个线程同时打选题接口观察有没有超卖、有没有死锁、响应时间是否可接受。这个压测不用很复杂但能提前暴露 90% 的并发问题。希望帮到你。本文还有配套的精品资源点击获取