简介:基于Web的毕业设计选题平台是一份面向高校师生及教务管理人员的完整项目源码,旨在解决传统选题管理中效率低、反馈慢的问题。系统采用Java与Vue前后端分离架构,涵盖课题发布、浏览、选择、审核及管理全流程,并通过人工智能算法优化选题推荐,提供更智能的个性化选题体验。资源压缩包共102个文件,大小约2.73MB,其中包含30个Java后台业务代码、12个Vue前端组件、12个JavaScript脚本、SQL数据库初始化脚本、XML配置及Maven工程文件等,覆盖后端服务、前端交互与数据存储三层。前端采用响应式设计,后端模块化分层清晰,并配有项目配置文件与数据库脚本,导入IDE即可运行调试,便于二次开发。已有45人学习下载,适合作为毕业设计选题平台开发参考,或用于Spring Boot、Vue、MySQL等技术栈的实战学习。
1. 基于Web的毕业设计选题平台设计与实现:先想清楚它是预约系统,不是信息发布系统
到四月中旬,毕业论文题目还在班群里传 Word 接龙,学生挨个私聊老师问哪个还能选,最后教务老师用 Excel 合并时发现两个学生选了同一个题。基于Web的毕业设计选题平台设计与实现,就是专门终结这种混乱的:教师在线发布课题,学生在规定时间内选题退选,管理员统一审核和统计。它本质上是一套带时间窗口、名额约束和状态流转的预约审批系统,难点不在页面,而在并发抢题、数据唯一性和权限控制。适合计算机专业学生拿来做毕业设计项目,也适合想真正把选题流程线上化的教务和教师。下面按我实际做这类系统的顺序展开。
2. 业务模型与技术选型:五张核心表和三条权限线把需求钉死
这类平台翻车通常不是代码写错,而是业务模型没定死就动手。我接到类似需求,会先画一张三角色流程图:教师出题、学生选题、管理员管过程和结果。三条权限线对应三套页面,谁能在哪个阶段做什么,表结构就跟着长出来了。模型对了,后端的接口和前端页面自然顺。
2.1 先划分用例边界:哪些功能要做,哪些明确不做
先列核心用例,防止后期被评审老师或临近毕业的学生加需求加到失控。教师端四个用例:申报课题并填写名称、简介、人数上限;查看自己课题的被选情况;审核或驳回学生申请;关闭不再收人的题目。学生端三个:按关键词或专业过滤已开放课题;在平台设定的时间窗内提交选题和退选;随时查看自己的审核状态。管理员端四个:批量导入学生和教师账号;审核教师课题是否开放;配置选题各阶段起止时间;导出最终选题名单。
“明确不做”也是需求的一部分。我一般会在设计文档里写清楚:不做站内聊天、不做论文查重、不做答辩分组提醒。原因很简单,这些功能会让状态机复杂一个量级,而且 Excel 也能勉强替代。把名额、状态、时间这三个点做扎实,平台的业务价值已经完整。剩下的功能全都属于可以后补的扩展项。
2.2 数据库设计:五张表之间的关系和三个最关键索引
表结构不需要多,sys_user、topic、select_record、time_config 再加一张公告表足够了。前四张是核心,公告只是顺手补的展示内容。用户表存三类账号,课题表存教师出的题,选课记录表存整个选题申请的流转,时间配置表让开放窗口可配置,不写死在代码里。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL COMMENT 'BCrypt 密文', role TINYINT NOT NULL COMMENT '1 学生 2 教师 3 管理员', real_name VARCHAR(32) NOT NULL, deleted TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE topic ( id BIGINT PRIMARY KEY AUTO_INCREMENT, teacher_id BIGINT NOT NULL COMMENT '关联 sys_user.id', title VARCHAR(200) NOT NULL, description TEXT, max_students INT NOT NULL DEFAULT 1, selected_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0 待审 1 开放 2 关闭', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_teacher (teacher_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE select_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, topic_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0 待审核 1 已通过 2 已退选', is_current TINYINT GENERATED ALWAYS AS ( CASE WHEN status IN (0,1) THEN 1 ELSE NULL END ) STORED, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_student_current (student_id, is_current), KEY idx_topic (topic_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE time_config ( id TINYINT PRIMARY KEY, phase VARCHAR(32) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, enabled TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里最值得展开的是 select_record 里的生成列 is_current。很多参考代码直接对 (student_id, status) 建唯一索引,那是错的:同一个学生第一次退选后 status 变成 2,第二次退选再插入 status=2 会撞唯一键;如果去掉唯一索引,学生又能并发提交多条待审核记录。生成列把“当前有效”映射成 1,把“已退选”映射成 NULL,MySQL 唯一索引允许多个 NULL 重复,于是数据库层面就保证了一个学生最多只能有一条待审核或已通过的记录。这条设计在论文里也值得单独讲。
关于 selected_count 字段,注意它表示“已占名额数”而不是“已通过数”。到底是提交申请就占名额,还是教师审核通过才占名额,这是第 3 章的核心分歧点。先记住表里已经有这个字段,后面代码会用到。
2.3 后端骨架:Spring Boot 是主流,FastAPI 和 SQLAlchemy 是另一条好路
java web 方向做这种管理系统,最稳的组合是 Spring Boot 3 加 MyBatis-Plus 加 MySQL 8。Spring Boot 的资料多,答辩时被问到任何一层都能找到对应参考实现;内嵌 Tomcat 也让部署简单,一个 java -jar 就能起服务。搭建时只需要在 pom.xml 引入 spring-boot-starter-web、mybatis-plus-spring-boot3-starter 和 mysql-connector-j,然后配置数据源,第一次跑通不需要额外东西。
application.yml里几个参数值得留意:数据库连接串加上useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai,否则中文乱码和时区错误会在后面轮番出现;MyBatis-Plus 的 mapper-locations 指向classpath*:mapper/**/*.xml,方便把复杂 SQL 写在 XML 里而不是拼在代码中。
如果你的团队更熟 Python,python web 框架里的 FastAPI 加 SQLAlchemy 写这种接口也相当舒服,尤其批量导入和统计出库时可以直接用 pandas。换框架不换业务模型,后面讲的事务、唯一约束和并发控制思想在两条技术路线里完全通用。我个人偏好 Spring Boot 是因为毕业设计生态里遇到问题容易搜到现成答案,新手跟起来压力小。
2.4 前端不要过度设计:Vue3 加 Element Plus 按角色拆三个入口
前端用 web 前端开发最常见的 Vue3 加 Vite 加 Element Plus 组合就够了。页面按角色拆:登录页、学生选题列表、我的选题、教师课题管理、管理员用户导入和统计页。不要一上来就设计复杂的动态菜单权限,三个固定入口已经能覆盖流程。
路由守卫只防“手滑”,不防“故意”。一个简化的 beforeEach 是这样:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && to.meta.role && to.meta.role !== store.role) { next('/401') } else { next() } })这段逻辑说明了两件事:第一,token 存在本地是多数毕设的常见做法,简单直接;第二,路由守卫只控制前端能不能进某个页面,拦截不了别人直接用 Fiddler 改请求或手工调用/api/admin/import。真正的角色判断必须在后端每个接口执行,从认证上下文里拿角色,而不是信任请求参数里带的 role 字段。这个原则后面第 4 章还会反复出现。
后端的包结构我建议严格分层,不要一个 Controller 写完所有逻辑。常见的做法是controller、service、mapper、entity、common五层,common 里放 Result 包装类、BizException 和全局异常处理器。分层不是形式主义,选题接口要在事务里同时操作 topic 和 select_record 两张表,没有 service 层承接,事务注解都不知道往哪里放。
3. 把核心流程跑通:选题、退选与并发名额扣减的代码实现
第 2 章把表和骨架立住之后,这一章解决的是平台最要紧的三件事:学生怎么安全提交选题、退选时名额怎么释放、以及多人同时抢一个课题时凭什么不会超卖。这三个问题承载了平台 80% 的业务复杂性。
3.1 选题接口:五步校验和一条条件 UPDATE
很多现成模板把选题接口写成:先查询课题、再判断名额、再插入记录、最后 update selected_count。低并发下没有问题,但到了开放第一天上午九点,几百个学生同时点按钮,两个线程会同时通过名额判断,产生超卖。所以我的做法是把名额扣减放到一条 UPDATE 的 WHERE 条件里,让数据库行锁保证原子性。
@Service public class SelectTopicServiceImpl implements SelectTopicService { @Resource private TopicMapper topicMapper; @Resource private SelectRecordMapper recordMapper; @Override @Transactional(rollbackFor = Exception.class) public void selectTopic(Long studentId, Long topicId) { Long existId = recordMapper.selectCurrentId(studentId); if (existId != null) { throw new BizException("已有待审核或已通过的选题,不能重复提交"); } Topic topic = topicMapper.selectById(topicId); if (topic == null || topic.getStatus() != 1) { throw new BizException("课题不存在或未开放"); } if (topic.getSelectedCount() >= topic.getMaxStudents()) { throw new BizException("该课题名额已满"); } int updated = topicMapper.occupySeat(topicId); if (updated == 0) { throw new BizException("手慢了,名额刚被抢完"); } SelectRecord record = new SelectRecord(); record.setStudentId(studentId); record.setTopicId(topicId); record.setStatus(0); try { recordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException("你已经提交过选题申请了"); } } }对应的 mapper XML 里的 occupySeat 是这样:
<update id="occupySeat"> UPDATE topic SET selected_count = selected_count + 1 WHERE id = #{id} AND status = 1 AND selected_count < max_students </update>逻辑说明:这里把“查名额、占名额”合并成了一步。只要 UPDATE 影响行数为 0,就说明课题不存在、已关闭或者名额已满,不需要再去对比旧值。@Transactional保证 selected_count 加 1 和插入 select_record 要么同时成功,要么同时回滚,不会出现计数变了但记录没插进去的脏状态。即使两个并发请求同时走到 insert,生成列唯一索引 uk_student_current 也会拦住第二条,抛出 DuplicateKeyException 后整个事务回滚,count 加的 1 也随之回滚。
参数说明:topicId 来自前端请求,studentId 必须从登录态解析,绝不能由前端传过来。selectCurrentId的 SQL 就是查 status in (0,1) 的记录,配合索引扫描非常快。这个方案用数据库的原子性代替了 Java 层的锁,单机多线程和将来多实例部署都适用。
3.2 教师审核与退选:状态流转和名额扣减必须同事务
选题申请提交后是待审核状态,教师可以通过或驳回。审核接口有一个容易漏的点:驳回时要释放名额,通过时不当选名额。因为 3.1 里提交申请时就占了名额,被驳回后名额不吐出来,课题就会越满越假。
@Transactional(rollbackFor = Exception.class) public void audit(Long teacherId, Long recordId, boolean pass) { SelectRecord record = recordMapper.selectById(recordId); if (record == null) { throw new BizException("选题记录不存在"); } Topic topic = topicMapper.selectById(record.getTopicId()); if (topic == null || !topic.getTeacherId().equals(teacherId)) { throw new BizException("只能审核自己课题下的选题"); } if (record.getStatus() != 0) { throw new BizException("该记录已被处理过"); } record.setStatus(pass ? 1 : 2); recordMapper.updateById(record); if (!pass) { topicMapper.releaseSeat(record.getTopicId()); } }这段代码里的一个关键设计是幂等。第一次审核后 status 变成 1 或 2,第二次再进来会命中“该记录已被处理过”,不会再重复释放名额。否则教师多点一次“通过”,后台就把同一个名额释放两回,课题人数就会对不上。
退选接口是审核的对称操作。学生只能退自己的记录,待审核和已通过状态下的退选都要释放名额,因为两种状态当初都占过名额。这里要注意:退选和驳回是两条代码路径,但名额释放逻辑必须收敛到同一个topicMapper.releaseSeat(topicId)方法里,避免只改了一处,另一处漏掉。事务边界上,改 record 状态和改 topic 计数必须在一个事务里,否则中间进程崩溃就会出现“记录退选了,人数没减”的翻车现场。
3.3 两种抢题模式:先到先得与志愿分配,选一种做深
如果学校规定“学生一次只能选一个课题,先到先得”,3.1 的方案就是完整的。但有些学校实行志愿制:学生可以填三个志愿,教师最终确定一个。这时候 select_record 的唯一索引就不能用了,因为同一个人会有多条“待处理”的记录。
常见做法是另建一张 topic_wish 表,字段是 student_id、topic_id、priority,允许同一学生插入多条记录;等申报结束后由管理员触发一个分配任务,先按学生志愿优先级排序,再结合教师确认或成绩排名,确定每人最终命中一个课题,把结果写回 select_record。分配规则可以很简单,也可以做成带权重的算法,但这套逻辑会让项目复杂度至少翻一倍。
我的实际建议是:个人毕设做先到先得加教师审核就足够了,肖愿制写进论文“优化方向”里讲,比现在硬做两种模式稳妥得多。毕业设计评审判的是你能否把一条流程做完整、考虑清楚边界,而不是功能塞得多满。
3.4 用 Fiddler 看请求报文:前后端联调别靠猜
前后端联调时最常见的场景是:前端说“我请求发了”,后端说“我没收到”,最后发现两边说的是两回事。用 Fiddler 抓包工具是最直接的排查方式。配置上,先让 Fiddler 监听 8888 端口并开启 HTTPS 解密,安装它生成的根证书;浏览器代理指向 127.0.0.1:8888 后,所有请求都会经过 Fiddler,按 URL 关键字过滤就能看到选题接口的原始请求和响应。
抓到请求后重点看三样东西:请求方法是不是 POST,Content-Type 是不是 application/json,Authorization 头有没有带上 token。下面这张表是我在实际联调里最常见的几类问题:
| 抓包现象 | 可能原因 | 处理方式 |
|---|---|---|
| 出现 OPTIONS 请求且返回 403 | 后端 CORS 没放开预检 | 配置 allowedOriginPatterns,不要用*加 allowCredentials 的组合 |
| 请求体是表单格式,后端却按 JSON 解析 | Axios 没设 Content-Type | 在请求拦截器统一设置 JSON 头 |
| 返回 200 但业务 code 是 500 | 后端抛了业务异常 | 去后端控制台看堆栈,别盯着前端弹窗猜 |
| 接口返回 401 | token 没带进 header | 检查请求拦截器是否从 localStorage 取 token |
这些判断依赖真实报文,而不是代码 review 时的“我觉得应该没问题”。养成联调先抓包的习惯,能省下大半天互相甩锅的时间。
4. Web安全、并发与部署:选题高峰期前的三道防线
平台功能跑通只是开始,真正决定上线当天会不会翻车的,是安全边界、并发控制和部署方式。很多毕设能演示但扛不住真实场景,问题都出在这一章。
4.1 SQL注入与越权:ORM不是免死金牌
MyBatis 的#{}会走预编译,但${}是字符串拼接。最典型的翻车点不在 where 条件,而在排序字段。有的同学为了做前端排序,把 orderBy 参数直接拼进 SQL:
String orderBy = request.getParameter("orderBy"); queryWrapper.last("ORDER BY " + orderBy);如果学生传title; DROP TABLE topic; --,这条 SQL 的破坏力可想而知。正确做法是白名单校验:
List<String> allowed = Arrays.asList("created_at", "selected_count", "title"); if (!allowed.contains(orderBy)) { throw new BizException("非法排序字段"); } queryWrapper.last("ORDER BY " + orderBy);越权是另一个隐蔽问题。选题记录的 id 是自增整数,攻击者遍历 recordId 就能查询别人的申请。后端每次都要用当前登录学生 id 作为查询条件,而不是只凭 recordId 查出来再用:
SelectRecord record = recordMapper.selectOne( new LambdaQueryWrapper<SelectRecord>() .eq(SelectRecord::getId, recordId) .eq(SelectRecord::getStudentId, currentStudentId) ); if (record == null) { throw new BizException("记录不存在"); }这里注意提示语不能区分“记录不存在”和“无权操作”,否则等于告诉攻击者这个 id 是存在的且属于别人。统一返回“记录不存在”是安全上常见做法。
4.2 并发抢题的硬约束:Synchronized 管不住集群和应用重启
有人会在选题方法上直接加 synchronized,这在单机单进程时确实有效,但有两个硬伤:第一,服务一旦多实例部署,锁就管不到另一台机器;第二,锁在 JVM 进程内,应用重启瞬间锁就消失,正在并发处理的请求依然会冲进去。
比 synchronized 更接近正确答案的是两种数据库方案。一种是SELECT ... FOR UPDATE悲观锁,先锁住课题行再判断名额,正确性没问题,但要求锁和事务在同一连接里,稍不注意就会出现锁不释放、死锁难排查。另一种是我在 3.1 用的方式:用一条带条件的 UPDATE 原子占座,影响行数为 0 就说明失败。两者相比,后者锁的粒度更轻,不需要额外事务工具,代码也更好解释。
唯一索引作为最后一道兜底防线不能省。并发再猛烈,程序逻辑再严谨,提交到数据库时唯一约束会再挡一次。把“数据库约束优先于代码逻辑”当成习惯,很多脏数据问题根本不会出现。
4.3 Nginx反向代理与静态资源缓存:一台服务器扛住选题高峰
选题开放第一天,学生会集中刷新页面和提交申请。纯靠 Spring Boot 同时处理静态资源和动态接口,Tomcat 线程会被占满。常见做法是用高性能 web 服务器 Nginx 做前置:静态资源直接由 Nginx 返回,动态接口反向代理到后端,同时把打包后的前端文件缓存起来。
server { listen 80; server_name topic.local; client_max_body_size 10m; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_connect_timeout 5s; proxy_read_timeout 60s; } location /assets/ { alias /opt/topic-web/dist/assets/; expires 7d; add_header Cache-Control "public, max-age=604800"; } location / { root /opt/topic-web/dist; index index.html; try_files $uri $uri/ /index.html; } }参数说明:client_max_body_size 10m是为了避免管理员上传 Excel 时返回 413;proxy_pass不带尾斜杠,保留/api/前缀,后端接口路径和本地开发保持一致;/assets/目录里的文件是 Vite 打包产物,文件名带 hash,可以放心设 7 天缓存,这是 Linux web 缓存最常见的配置方式;try_files解决前端 history 路由刷新 404 的问题。
一个特别容易翻车的细节:如果后端又把 context-path 设成了/api,Nginx 反代后再加一层前缀,接口路径就变成/api/api/login。建议后端不设 context-path,由 Nginx 统一处理/api前缀,两边只保留一层,排查起来会简单很多。
4.4 登录态与 CORS:从 JWT 到 HttpOnly Cookie 的选择
登录态常见的做法是 JWT:登录成功后后端返回 token,前端存 localStorage,每次请求在 Axios 拦截器里加 Authorization 头。它的好处是无状态、方便前后端分离,缺点是对 XSS 敏感,前端只要有一处 innerHTML 拼了用户输入,token 就可能被偷走。所以 JWT 方案下要求前端不直接用 v-html 渲染教师填的课题描述,所有富文本内容都要先转义。
安全要求高一点,就把会话改成 HttpOnly Cookie,再配上 Secure 和 SameSite=Lax。Cookie 由浏览器自动携带,JavaScript 读不到,XSS 的损失面会小很多。但跨域配置也要跟上:后端 CORS 的 allowedOrigins 不能是*,因为带 Cookie 的跨域请求不允许通配来源;本地开发用http://localhost:5173前端地址,前后端都统一好,否则登录成功但后续接口不带 Cookie,看起来就像“登录失效”。
5. 避坑指南:从开发到上线最容易踩的五个坑
这一章写的是真实项目里反复出现的排错记录。每一条都按现象、原因、解决三部分写,按顺序排查能省出大量时间。
5.1 重复选题:数据库唯一索引没建,程序判断再细也会漏
现象:同一名学生出现在两个课题的通过名单里,退选记录混乱,最终名单导出后还要人工核对。
原因:代码里“先查再插”的逻辑在并发下并不可靠。两个请求同时查到“该学生没有选题记录”,然后各自插入一条,如果数据库层没有唯一约束兜底,就都成功了。更隐蔽的来源是 Excel 导入旧数据时,本身就有重复记录混在里面。
解决:select_record 表用 2.2 里的生成列唯一索引 uk_student_current;导入前先按学号去重;已经污染的库要先清理脏数据再建索引,否则索引建不上或被现有数据挡住。
5.2 退选名额不释放:修 Bug 只改了一处逻辑
现象:学生退选成功,自己也能重新选别的课题,但原课题在教师端仍显示满员,其他学生进不来。
原因:退选和驳回是两个独立入口,当时只在其中一个分支调用了 releaseSeat。另一个分支改完状态就走,名额没有减回去。
解决:把名额释放收敛到一个topicService.releaseSeat(topicId)方法,所有把选题状态改成 2 的地方统一调用它;退选接口做幂等,第二次退选直接返回成功而不是报错,避免前端重复点击造成连锁问题。
5.3 选题时间窗口错乱:服务器时区与前端时区打架
现象:管理员明明配置了 9 点开放,学生 8 点 55 就能提交;或者刚过截止时间 1 分钟,接口把请求放过去了。
原因:服务器操作系统是 UTC 时区,数据库连接串没指定 serverTimezone,Spring Boot 的 Jackson 又按默认时区序列化。前端用 JavaScript 的new Date()换算成北京时间,和后端存进 MySQL 的 DATETIME 对不上。
解决:数据库连接串加serverTimezone=Asia/Shanghai;实体时间字段用 LocalDateTime,不用java.util.Date;部署脚本里加-Duser.timezone=Asia/Shanghai;所有窗口判断由后端以服务器时间为准执行,前端只负责展示。
5.4 Excel 批量导入题目:乱码、重复、格式错一次性全来
现象:管理员上传模板后,系统提示“第 3 行标题为空”,但用记事本打开 Excel 文件明明有字;同样的模板导入两次,系统里出现两套一样的题目。
原因:旧的 CSV 文件是 GBK 编码,后端按 UTF-8 读,中文自然乱码;模板表头顺序和解析代码不一致;判重键设计得不对。
解决:用 EasyExcel 按实体类读取,自己 split 字符串拼行很容易在引号、换行符上出问题;模板固定列顺序;判重用“教师工号加题目标题”的组合,而不是只凭题目名;校验失败不中断整批,把出错行和原因收集起来返回让管理员下载,而不是到第 10 行才报错让人无从查起。
5.5 部署后刷新 404、接口 502:路由和代理各背一口锅
现象:Nginx 下访问首页正常,点浏览器刷新变成 404;登录接口返回 502 Bad Gateway。
原因:前端 history 路由在 Nginx 里没有 fallback;后端服务没起来,或 Nginx 转发地址写错了端口;后端 context-path 和 Nginx 前缀叠加重叠。
解决:先按顺序排查。ss -lntp看后端 8080 端口是否在监听;curl http://127.0.0.1:8080/actuator/health看服务是否真正可用;最后确认 Nginx 配置里try_files $uri $uri/ /index.html;写没写。502 的另一个常见来源是 Nginx 转发到http://127.0.0.1:8080/api,而后端 context-path 又是/api,两层拼成/api/api,后端根本没有这个路由,自然全部 502。
6. 交付前的检查清单与两个值得做的扩展
临近交付,功能都跑通不代表能上线。我每次给这种系统做验收,会先按一张固定清单过一遍,再跑一个并发脚本,最后才去写文档和演示准备。
| 检查项 | 通过标准 |
|---|---|
| 数据库唯一约束 | 同一学生并发提交两次选题,第二次被拒绝且返回明确提示 |
| 时间窗口 | 未到开放时间、超过截止时间时,服务端接口都拒绝提交 |
| 名额上限 | 最后一个名额被抢走后,后续请求返回“名额已满” |
| 权限边界 | 学生访问教师接口返回 403,修改 recordId 访问他人记录返回“记录不存在” |
| 退选对称 | 待审核和已通过两种状态退选后,课题人数都减一 |
并发脚本可以很简单,不需要引入 JMeter。用一个 for 循环模拟 20 个并发请求抢同一个名额只有 1 的课题,成功数必须等于 1:
for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/api/student/select-topic \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"topicId": 1}' \ -o /tmp/resp_$i.json & done wait grep -h "成功" /tmp/resp_*.json | wc -l这段脚本里,每个请求都带相同 token 的前提下,成功数等于 1 才说明并发控制真正生效。如果结果大于 1,优先查唯一索引建没建,再查事务回滚有没有生效。
扩展方向我做两个。第一是志愿分配算法:在现有表结构上加一张 wish 表,把先到先得改成志愿加教师确认,核心是优先级排序和最终唯一性校验,这个方向写进论文非常加分。第二是统计导出:按专业、课题方向和导师维度导出 Excel,甚至用 web 页面直接生成 PDF 选题确认单,技术上不难,但能补上管理侧最后一块拼图。
我第一次做类似的选题系统,部署当天就发现两个学生同时出现在同一个课题的通过名单里,原因就是我当时没建唯一索引,后续又从 Excel 导入造了一条脏数据。后来我养成了一个习惯:凡是“一个用户同一时刻只能有一条 X”的规则,先写数据库约束,再写代码判断。顺序反了,数据库迟早用脏数据教你做人。希望帮到你。
本文还有配套的精品资源,点击获取