每年三四月份,各大高校的教务通知群里就开始刷屏——“毕业设计选题系统即将开放,请在规定时间内完成选题确认,逾期不候”。后台的老师们这时候往往是最焦头烂额的,手动Excel统计选题、学生反复来问名额还剩多少、老师们被几十封邮件和私信淹没。我当年给自己导师做的第一个Java Web项目,就是解决这个问题的毕业设计选题系统。基于B/S架构,用Java技术栈从零搭建,把“教师出题—学生选题—双向确认—过程管理”整条链路搬上网页。这篇文章就基于这个项目,把我做系统时的完整设计思路、数据库建模、核心代码实现和踩坑记录完整梳理一遍,给正在做类似毕业设计或者真实交付项目的同学一个可直接复现的参考。
1. 为什么是B/S架构:从选题场景倒推技术选型
1.1 高校选题场景的三大痛点
在做技术选型之前,我先把“用户在哪、在什么环境里用、有多少人用”这几个问题想清楚了。毕业设计选题这个场景有很明显的特殊性:首先它的使用时间高度集中,一般就是每年春季学期开学前后的一到两周,全年级几百上千名学生和几十上百位老师会在同一时间段内涌入系统;其次它的参与角色多,不是单纯的学生选题那么简单,还牵涉课题申报、院系审核、管理员发布、学生多轮选择、教师确认名额等多个环节;第三个痛点是信息不透明,选题名额剩多少、老师倾向于接收谁、中途有没有学生放弃,这些信息在传统的Excel接力赛里根本无法实时同步。
其实最开始我也纠结过要不要用传统的方式,做一个Excel表格共享、再把最终结果发到群里。但这个方案稍微推演一下就会发现根本行不通:几十个老师同时编辑一个表格,版本冲突就已经够让人崩溃;学生没法在实时名单上看到哪些题目有空缺;老师也没办法只接收自己认可的学生。所以系统化、在线化的双向选择平台,在这个场景里基本是刚需。一句话总结,这个系统要解决的核心问题就是“让正确的课题找到合适的学生,让合适的学生匹配到不超员的课题”。
1.2 B/S架构为什么比C/S架构合适
这里有个很常见的争论:为什么不用C/S架构?C/S架构比如局域网版的客户端系统,在校园网环境里也能跑,但有两个绕不开的问题。第一,客户端分发成本很高,几百个学生要逐台安装配置,不同院系的电脑环境千奇百怪,光解决环境问题就能耗掉一整周;第二,系统升级要重装客户端,每次改一个需求就要全部人更新一遍,运维成本完全不可控。B/S架构只需要部署一个Web服务器,学生和老师打开浏览器输入网址就能用,这是它最大的优势。
我最终选的部署方案是校内一台服务器上跑一个Java的Web应用,数据库用MySQL,浏览器端不挑设备,Windows、Mac、手机浏览器都能访问。实测下来,300人左右的学院选题,高峰期几百并发,只要服务器配置不拉胯,2核4G起步、8G更稳,Spring Boot默认的Tomcat线程池完全扛得住。这也是B/S架构在这个场景里最有说服力的地方:入口统一、升级简单、并发可扩展。如果你胆子大一点,还可以把系统部署到云服务器,学生在宿舍躺着用手机就能完成选题,体验上会比机房排队好太多。
1.3 角色权限体系:三类用户如何互相制约
这个系统在权限上有个很关键的设计原则:不同角色的操作界面和功能必须严格隔离,不能让学生打开管理员的菜单。我按实际业务划分了四个角色:系统管理员、院系管理员、教师、学生。它们之间的制约关系是层层递进的,具体可以看下面这个表:
| 角色 | 核心职责 | 关键操作 | 边界约束 |
|---|---|---|---|
| 系统管理员 | 全局配置与数据维护 | 账号管理、学年学期参数、开放关闭选题时间、数据统计 | 不干预具体课题的双选内容 |
| 院系管理员 | 本院课题审核与调剂 | 审核教师课题、处理调剂申请、查看本院数据 | 只能操作自己院系的数据 |
| 教师 | 出题与选人 | 申报课题、查看申请、确认或拒绝学生、过程评价 | 只能管理自己名下的课题 |
| 学生 | 选题与提交材料 | 浏览课题、申请课题、提交阶段文件 | 同一学期只能有一条有效申请 |
这套角色模型看起来不复杂,但它决定了整体的功能菜单和页面权限。实现的时候我用Spring Security加自定义注解,或者直接用一个拦截器做角色校验都是可以的。如果项目周期紧,我建议先用拦截器加角色字段做精细鉴权,把主要精力放在核心业务流程上,权限安全后面再补也不迟。
提醒一句:做权限体系的时候,不要只在前端隐藏菜单,后端接口也要做角色校验。很多人图省事只在前端控制,结果学生换个接口请求直接能看到老师的数据,这在毕业设计答辩和真实上线中都是大忌。
2. 双向选择流程如何从零落地:状态机驱动的业务链路
2.1 从课题申报到双选成功的完整时序
这是整个系统最核心的地方。双向选择不是学生点一下“选中”就完事,它是一个完整的状态链路。我把整个流程拆成六个环节:
第一步,教师登录系统申报课题。课题至少要填这些字段:课题名称、研究方向、课题简介、对学生的要求、可用名额、课题类型,比如工程设计还是理论研究,以及学年学期。第二步,院系管理员审核课题,审核通过后的课题进入“待发布”状态,之后由管理员统一发布。审核这一环不能省,否则每年都会出现题目重复、用词不当或者研究方向对不上的课题被直接放出来。
第三步,学生浏览已发布的课题,并选择自己心仪的课题提交申请。注意,这里我设计成“申请”而不是“直接占用”,这是双向选择的关键区别。第四步,教师查看申请列表,逐个确认或拒绝。教师确认后,课题的剩余名额减一,学生和教师都收到最终确认通知。第五步,如果名额满了,课题状态自动变为“已满员”,其他学生还能浏览但不能继续申请。第六步,管理员可以发起“调剂”环节,对未成功双选的学生开放调剂,由管理员或院系管理员手动分配课题。
2.2 双向选择的核心策略:先到先得还是教师主导
“双向选择”这四个字的关键在于,它既要照顾学生的意愿,也要尊重教师的意见。所以在系统设计上,我把选择策略做成可配置的。策略A是先到先得:学生提交申请后,只要名额还有,就直接占用一个名额。策略B是申请制:学生提交申请后,课题状态依然显示“可申请”,但最终需要教师确认后,学生才算正式被接收。
这两种策略各有应用场景。比如基础课程设计、题目明确且不挑人的课题,先到先得效率高;而毕业设计这种带有研究性质的课题,多数老师希望先看到学生的成绩和方向再进行确认,更适合申请制。最终我的系统里是让教师在课题发布时自主选择策略。这个设计在答辩时是一个非常亮眼的点,因为你能明确说出,我的系统用状态机驱动了不同策略下的业务流转,而不是一个写死的逻辑。从工程角度讲,策略选择本质上是给课题表加一个字段,然后在选题Service里根据这个字段走不同的分支,代码量并不大,但业务适应性会强很多。
2.3 状态机设计:让流程有据可循
双选流程能顺利跑通,本质上靠的是严格的状态控制。我定义了两套状态:课题状态和选题记录状态。课题状态包括草稿、待审核、审核通过、已发布、已满员、已关闭;选题记录状态包括已申请、教师已通过、教师已拒绝、已放弃、调剂中、双选完成。
每套状态对应一组允许的操作。比如学生只能申请“已发布”且未满员的课题;只有处于“已申请”状态的记录,教师才能确认或拒绝;一旦记录进入“双选完成”,任何一方都不能随意修改,只能由管理员介入处理。这个状态机看起来简单,但实际开发里我建议把它集中在一个枚举类中管理,而不是散落在Service的各个if/else里。当你后续要加“撤销申请”“名额释放”之类的功能时,只需要在枚举里扩展一个状态,再补上对应的流转方法,改动非常小。这一点我在维护了一版之后体会特别深,状态散着写的版本改一次崩一次,集中管理之后整条链路清晰了太多。
3. 数据库设计:核心表结构与数据一致性把住最后防线
3.1 核心表的拆分与字段设计
数据表设计是这类管理系统的地基,设计得好后面开发顺风顺水,设计得不好后期就是不停地打补丁。我把核心表拆成了六张左右,结构简洁但能覆盖完整业务链路:
| 表名 | 关键字段 | 作用说明 |
|---|---|---|
| sys_user | 用户ID、账号、密码、姓名、角色编码、所属院系、状态 | 统一管理四类角色账号 |
| topic | 课题ID、教师ID、课题名称、研究方向、简介、名额、剩余名额、策略类型、状态 | 承载教师申报的课题主数据 |
| select_record | 记录ID、课题ID、学生ID、教师ID、申请时间、状态、备注 | 记录每次双选交互链路 |
| stage_file | 阶段ID、记录ID、阶段类型、文件路径、提交时间、评分状态 | 记录开题、中期、结题阶段材料 |
| notice | 通知ID、标题、内容、发布时间 | 管理员发布公告和通知 |
| sys_config | 参数名、参数值 | 存储学年学期、开放时间等全局配置 |
3.2 名额控制为什么不能只靠应用层
这里要重点讲一个大坑:课题名额的控制必须放在数据库层面做约束,不能只靠Java代码里的判断。假设学生A和学生B同时并发提交申请,如果两个请求都通过了“剩余名额大于0”的判断,然后各自执行UPDATE语句把剩余名额减一,就很容易出现名额被扣成负数的情况。这个问题的标准解法有两个。
第一个是使用带条件的UPDATE:UPDATE topic SET remaining = remaining - 1 WHERE id = ? AND remaining > 0,执行后判断影响行数是否大于0,如果等于0就说明名额已经抢完,直接提示学生课题已满。这种“乐观锁式的SQL条件更新”比先SELECT再UPDATE要安全得多。第二个是给select_record表加唯一约束,比如UNIQUE(student_id, semester),保证同一个学生在同一学期只能有一条有效的选题记录,防止学生重复下单。如果系统允许学生同时申请多个课题,那就再加一个状态条件,用部分唯一索引保证同一时间只能有一个待确认记录,这个要看你的数据库版本支不支持。
3.3 数据一致性与历史回溯
做这类系统还有一个容易被忽略的点:历史记录不能随意覆盖,要有状态变更日志。比如教师先拒绝了学生A,但后来学生A又申请了其他课题,这个流程如果只靠主表字段覆盖,后面溯源就很难。我的做法是加一张选题操作日志表,记录谁在什么时间对哪条选题记录执行了什么操作、操作前状态、操作后状态。
这张日志表平时看着没什么用,但在真实运营中价值非常大。比如学生申诉“我明明申请了为什么老师没看到”,一查日志就能定位是哪个环节出了问题。而且这在毕业设计答辩中也是一个很有说服力的亮点:系统具备完整的可审计性。数据一致性还要注意一点:凡是涉及“扣名额 + 插记录”这种组合操作,必须放在同一个事务里,否则就会出现记录插入成功但名额没减的脏数据。这个我用@Transactional(rollbackFor = Exception.class)处理,后面在排查部分会再展开。
4. 技术选型与核心代码实现:把高并发名额扣减落到实处
4.1 技术栈怎么选:Spring Boot + MyBatis-Plus + MySQL
这个项目我最终用的是Spring Boot 2.7 + MyBatis-Plus + MySQL 8 + Thymeleaf,开发起来比传统的SSM框架舒服得多。为什么不用SSH或者纯Servlet?说句实话,Spring Boot把配置的大量复杂度都吃掉了,内置了Tomcat,打一个jar包就能跑,非常适合这种中小型Web项目。而MyBatis-Plus相比原生MyBatis的一个巨大优势就是省去大量的单表CRUD代码,内置的分页插件、条件构造器能让你把精力放在业务逻辑而不是样板代码上。
如果你的需求是前后端分离,后端只写REST接口,前端用Vue3加Element-Plus,这样做出来的页面会更好看,也能体现对现代前端框架的理解。但如果你是一个人做毕业设计,时间比较紧,用Thymeleaf做服务端渲染反而效率更高:不用处理跨域、不用维护两套环境、部署也更简单。我在做这个项目时用的Thymeleaf加一点点原生JavaScript,整个工程结构非常清爽。当然,下面是个人偏好的组合,大家可以根据自己的情况调整。
4.2 核心模块实现:学生提交选题与教师确认
我拿“学生提交选题”这个接口来做示例,把核心逻辑拆成Service层的一段关键代码。学生提交选题时,Service要做三件事:校验课题状态和名额、校验学生是否已有有效申请、插入选题记录并扣减名额。
@Transactional(rollbackFor = Exception.class) public Result applyTopic(TopicApplyDTO dto) { Topic topic = topicMapper.selectById(dto.getTopicId()); // 1. 校验课题状态和名额 if (!TopicStatus.PUBLISHED.equals(topic.getStatus()) || topic.getRemaining() <= 0) { return Result.error("课题不存在或已满员"); } // 2. 校验学生是否已存在有效申请 Long exists = selectRecordMapper.countValidApply(dto.getStudentId()); if (exists > 0) { return Result.error("你已有待确认的选题申请"); } // 3. 带条件更新名额,防止并发超售 int updated = topicMapper.decrementRemaining(dto.getTopicId()); if (updated == 0) { return Result.error("手慢了,课题名额已被抢完"); } // 4. 插入申请记录 SelectRecord record = new SelectRecord(); record.setTopicId(dto.getTopicId()); record.setStudentId(dto.getStudentId()); record.setStatus(SelectStatus.APPLIED); selectRecordMapper.insert(record); return Result.success(); }这里最关键的是第3步的带条件更新SQL,它是整个双选系统在高并发下不会出乱子的核心保证。对应的Mapper方法大致是这样:
@Update("UPDATE topic SET remaining = remaining - 1 WHERE id = #{topicId} AND remaining > 0") int decrementRemaining(Long topicId);如果你只做“先查后改”,并发一高就会超额,这段代码在面试或者答辩的时候是可以直接拿来说的亮点代码。教师确认学生申请的接口类似:先校验记录状态必须是“已申请”,然后更新记录状态为“已通过”,如果这个课题是申请制,还要做一次名额校验。注意教师确认和名额扣减也要放在同一个事务里,因为“未通过时名额不下调、通过时名额减一”这两个动作必须原子发生。
4.3 过程管理模块:从选题完成后到毕业答辩
选题完成后,系统并没有结束,后续还有一个很重要的过程管理模块。我把这个过程拆成几个阶段:开题报告提交、中期检查、论文终稿提交、答辩成绩记录。每一个阶段本质上都是一张“阶段记录 + 文件上传”的组合表。
我的实现是:一张阶段配置表定义了一个学年里有哪几个阶段节点,比如开题截止日期、中期截止日期;一张阶段材料表记录每个学生每个阶段的上传状态和文件路径。教师登录后在“过程管理”菜单看到自己名下学生,逐个审定阶段材料是否通过。这里要注意一个点:文件上传一定要做好类型和大小限制,否则学生经常传个几十MB的录屏过来,直接把服务器磁盘打满。
文件上传我用的是本地磁盘存储方案,生产环境更好的做法是用OSS或MinIO对象存储。毕业设计为了简单,可以先把文件放在服务器指定目录下,数据库里存相对路径。唯一要注意的是文件名不要直接存用户上传的原名,因为中文名和特殊字符很容易导致路径问题,我习惯用UUID重命名,原文件名单独存一个字段,展示的时候再拼回去。这样既能保证存储安全,也不会丢失原始信息。
4.4 权限控制落地与菜单动态渲染
这一节说说权限控制怎么落地。如果不想引Spring Security那套重东西,可以自己写一个HandlerInterceptor,在请求进入Controller之前通过用户角色和请求路径做校验。我这里采用了基于角色的方法:每个用户登录后把角色标识放进Session,再自定义一个注解@RequireRole("teacher")加在教师接口的方法上,拦截器里读取注解并判断当前用户角色是否匹配。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String value(); }前端这边的菜单就简单了:登录之后根据角色渲染不同的菜单列表,菜单数据从数据库或后端接口读取,这样不同角色看到的功能入口天然隔离。这个方案实现成本低,角色只有四类,完全够用;如果是大型多租户系统再考虑引入Spring Security那套,中小型项目完全不必上。权限这块要提前设计,不要等到功能写完了再回头加,否则每个接口都要改,非常痛苦。
5. 常见问题与排查技巧实录:并发、事务与部署避坑
5.1 并发选同一课题导致超额怎么办
这是最容易翻车的一个问题。我的排查思路是三步走。第一步,检查表结构有没有唯一约束,给选题记录表加上(student_id, semester)的唯一索引,很多问题可以从源头阻断。第二步,检查名额扣减SQL有没有写成带条件的UPDATE,如果写成了UPDATE topic SET remaining = remaining - 1 WHERE id = ?,那并发一高必出负数。第三步,给状态流转加一个乐观锁版本号,避免两个请求同时把同一个状态覆盖掉。这三步做完,超额问题基本就绝迹了。
另外,在实际压测的时候,建议用JMeter或者Postman的Runner功能模拟两三个学生同时点申请,专门验证临界情况。不要觉得麻烦,这个验证过程五分钟搞定,却能帮你发现潜在的并发隐患。我当时就是用一个脚本并发请求了20次,果然复现了超额问题,然后才改成条件更新的SQL。这种“现场发现问题、现场修复”的经历,在答辩讲出来反而特别加分。
5.2 提交后页面不刷新、状态还是旧的
这个问题的根源多半在浏览器缓存或者前后端异步请求没有正确刷新局部视图。如果是服务端渲染,提交成功之后一定记得用Redirect而不是Forward,防止用户刷新页面时重复提交表单。如果是Ajax请求,提交成功后重新调用查询接口刷新列表,不要只改本地的临时数据。
还有一个细节:用了@Transactional的方法要注意回滚条件设成rollbackFor = Exception.class,否则有些RuntimeException之外的异常不会触发回滚,就会出现“记录插入了但名额没减”这种脏数据。这个问题非常隐蔽,因为普通业务里很少抛非运行时异常,但只要你的代码里自定义了一个Checked Exception,并且Service方法标注了throws,就极容易踩坑。我在学生选题的Service方法上把这些都处理好了,后面代码稳定很多。
5.3 中文乱码、文件上传失败等基础坑
中文乱码这个问题,十个人里九个遇到过。排查清单在这里:数据库连接URL要加characterEncoding=utf8;JDBC驱动连接参数要配置useUnicode=true&characterEncoding=utf8;项目统一使用UTF-8编码,HTTP请求和响应的字符集也要设置。文件上传失败则优先确认spring.servlet.multipart.max-file-size和max-request-size有没有设置,默认1MB很快就会超标;其次确认上传目录的写权限,很多服务器上/tmp目录是受限的。
我把容易踩的几个基础坑整理成了一张速查表:
| 问题现象 | 排查方向 | 解决方案 |
|---|---|---|
| 中文乱码 | 数据库连接、页面编码、HTTP字符集 | 统一UTF-8,连接URL加characterEncoding |
| 文件上传超限 | Spring配置文件未设置大小 | 调大max-file-size和max-request-size |
| 上传后文件打不开 | 文件名被改、存储路径错误 | 用UUID重命名,原文件名独立存储 |
| 刷新页面重复提交 | 使用了Forward而非Redirect | 提交成功后Redirect重定向到查询页 |
| 状态被并发覆盖 | 缺少乐观锁或唯一约束 | 加version版本号、加唯一索引 |
5.4 部署与演示环节的实用建议
最后给一个非常实在的建议:使用Spring Boot打包成单个Jar包,内置Tomcat,配一个MySQL数据库,部署真的就是一条命令的事。Jar包放到服务器上,nohup java -jar xxx.jar &就能启动。如果服务器上装了运维面板或者Docker,那更简单,Docker一条docker run命令挂载好数据库和目录就能跑。部署前记得把数据库初始化脚本跑一遍,并确认服务器防火墙放行了8080端口,不然只能本机访问,问题排查起来会绕弯。
答辩演示前务必准备一份预置数据:两个教师、十个课题、若干学生账号、几条已经走了部分流程的选题记录。如果你现场从零开始注册教师再录课题,光填表单就能耗五分钟,体验极其糟糕。预置好数据,双击打开首页就是丰富的业务状态,答辩老师印象会好很多。另一个实用技巧是准备好一个“高并发抢名额”的演示脚本,现场模拟多个学生同时申请同一个课题,展示名额不会超卖,这比口头讲“我的系统有并发控制”有说服力得多。
我自己在做这个项目时还有一个体会:状态机的设计比UI界面重要得多,把业务流转理清楚了,后面所有功能都是水到渠成。顺序做错,代码越写越乱。这个选题系统做完之后,后续如果要扩展,可以做消息通知、图表统计、批量导出Excel、延期管理等功能,只要底层的表结构和状态流转留好了,这些都是增量开发的事情。