每年高考出分后,报考志愿就像一场没有硝烟的战役。考生手里攥着分数,眼里盯着院校,心里纠结着专业,这个时候,一个“基于Spring Boot的大学志愿填报系统”如果能把院校数据、分数线、招生计划都整合起来,还能辅助模拟投档,就特别有实际意义。
这个项目是模拟项目X,核心目标是把高考志愿填报从“翻手册、做Excel对比”转成一套在线流程:考生登录后查院校、看专业、评估录取概率、填志愿;管理员维护院校和专业数据;后台还能按平行志愿规则做模拟投档。表面上是一个毕业设计,实际上牵扯到用户权限、规则校验、数据建模、并发处理这些真东西。我拿到需求之后第一反应是,这比想象中要重,因为它不是普通CRUD,而是要把一套招生规则翻译成代码逻辑。
这篇博文我会结合整个设计与实现过程,把架构思路、表结构、核心逻辑和踩过的坑一条条讲清楚。如果你也想做志愿填报、选课报名、考试系统这类“用户选资源、规则限流程”的项目,可以直接把这里面的框架和思路拿去用。
1. 志愿填报系统到底解决什么问题
1.1 从场景痛点看系统价值
志愿填报的时间窗口很短,考生要在几百所院校、几千个专业里选出几十个组合,本质上是一个信息筛选加决策排序的过程。传统模式下,院校信息分散在各自官网,历年分数线在各种PDF和论坛里,考生和家长一边查一边抄,很容易漏掉“服从调剂”之类的关键选项,或者把院校志愿顺序填反。这个系统要解决的第一个痛点,就是把分散的信息结构化地收拢到同一个平台里,按省份、批次、分数区间组合查询,考生不用再同时开七八个网页来回切。
第二个痛点是规则约束。志愿表不是随便填的,院校志愿个数有上限、每个院校下专业志愿个数有上限、是否服从调剂标记要规范、同一批次不能重复填同一所院校。这些规则靠人工检查既慢又容易出错,所以系统里必须内置校验机制,在提交志愿时自动拦截违规数据,并明确告诉用户哪里不合法。
第三个痛点是决策辅助。光有基础数据还不够,考生真正想知道的是“我这个位次,上这所学校稳不稳”。系统需要把历年最低投档位次和考生当前位次做对比,给出一个参考性的“冲、稳、保”区间。这个功能不需要做到机器学习预测那么复杂,但必须有可解释的数据依据,考生才敢拿它做参考。
1.2 这类系统的通用适用场景
认真想一下,志愿填报系统的业务模型,和很多信息系统是相通的。选修课抢课系统、考试报名系统、培训排班系统,本质上都是“用户 + 资源 + 时间窗口 + 规则约束”的组合。把院校表换成课程表,把志愿表换成选课表,一套骨架能复用过去。
所以我在设计这个项目时,特别注意两件事:一是分层清晰,业务规则尽量从Controller里抽出来,放进Service层统一管理;二是规则要尽量配置化,比如志愿数量上限、批次名称这些,不要写死在代码常量里,而是放到配置表。否则后面如果要把系统改造成“多省份多批次模式”,你会发现每个省份的政策细节都不太一样,代码里写死的东西会让你改到怀疑人生。
2. 技术选型与整体架构设计
2.1 为什么选Spring Boot
给这种后台管理系统选框架,Spring Boot几乎是最不容易翻车的一个选择。首先是生态成熟,做这个系统要用的东西,Spring Boot都有现成的起步依赖:安全认证用Sa-Token或者Spring Security,数据库操作用MyBatis-Plus,缓存用Redis,接口文档用Knife4j,打包用Maven,组合起来非常顺。其次是约定优于配置,相比传统SSM项目里那一大堆XML,Spring Boot把大部分配置自动化了,对单人开发的项目来说效率提升很多。第三是社区资料极其丰富,遇到问题搜一下基本都有答案,尤其在毕业设计和短期项目场景下,这直接决定你能否按时交付。
有人会问为什么不用Node.js或者Python的FastAPI。不是不能用,而是这个项目的核心场景是重业务、重事务、重权限的管理系统,Java在这个赛道最稳。加上国内高校课程体系里Java本来就是主流,后续接手维护的门槛低,这一点在课程设计场景里尤为重要。技术选型不一定追最新,更重要的是团队熟悉度和长期维护成本。
2.2 系统整体架构
整个系统采用前后端分离结构。后端用Spring Boot提供RESTful API,前端用Vue加Element UI做管理界面和用户界面。有同学问,既然做毕业设计,能不能直接用Thymeleaf模板渲染、省掉前端工作?我的看法是:时间实在紧张可以,但前后端分离更值得做,因为接口设计、联调、部署这些环节本身就是正式工作中要面对的,早练早受益。
后端经典四层结构:
- Controller层:接收参数、做基础校验、调用Service,返回统一Result对象
- Service层:业务逻辑集中在这一层,尤其像志愿提交、模拟投档这种带复杂规则的逻辑必须放Service,保证事务边界
- Mapper层:基于MyBatis-Plus完成单表CRUD,复杂查询用XML写SQL
- 工具层:JWT工具类、校验工具类、日期工具等抽出来,避免Controller和Service里堆工具代码
前端部分花的时间比预期多,但收获也大。Element UI的表单校验、表格组件、日期选择器和这个系统很匹配。尤其是志愿表编辑页面,需要动态增删院校行,我用了表格加弹窗的组合,前端校验规则和后端校验规则尽量保持一致,这样用户不用等提交被驳回才知道填错了。
2.3 接口统一返回格式与异常处理
接口设计上,我所有接口都返回同一个Result对象,结构是code、message、data三个字段。code为200表示成功,其他为业务错误码。这样前端axios拦截器可以统一处理错误提示,不用每个接口各自判断。
错误码我用枚举类定义,比如:
public enum ResultCode { SUCCESS(200, "操作成功"), PARAM_ERROR(400, "参数错误"), UNAUTHORIZED(401, "未登录或登录已过期"), FORBIDDEN(403, "没有权限"), BUSINESS_ERROR(500, "业务处理失败"); }全局异常处理用@RestControllerAdvice配合@ExceptionHandler,把参数校验异常、业务异常、系统异常分开处理。这里有一个建议:不要在Controller里try catch包住所有异常,而是让异常抛到全局处理器统一记录日志和返回提示,Controller代码会干净很多。我在实际项目里看到太多Controller里塞了一大堆try catch,最后日志里什么都查不到,很难定位问题。
3. 核心功能模块拆解
3.1 用户与权限模块
系统里的角色分三类:考生、管理员、院校老师。设计时没有把角色字段用死,而是用角色码加后端路由拦截的方式控制。考生有自己的操作台,能维护个人信息、查院校、看专业、填志愿、看公告;管理员管院校审核、专业审核、公告发布、数据统计;院校老师维护本校的招生计划和历年分数线。
认证方案选了JWT而不是传统Session。原因很实际:前后端分离下,Session要处理跨域Cookie问题,JWT把状态放到Token里,前端拿到后放在请求头里带回来,服务端无状态,部署和扩展都方便。配合拦截器统一校验Token,在Controller上用注解声明权限,Spring Boot里实现成本很低。
不过JWT有个注意点:Token一旦签发,不能修改里面的角色信息,但Token本身可能被转发。所以后端每个敏感接口都要校验权限,不能只靠前端隐藏按钮。比如模拟投档接口,如果只做了登录校验、没做管理员角色校验,被考生用户逆向调用就是安全事故。
3.2 院校与专业信息管理模块
院校和专业数据是整个系统的地基。设计上我拆成了两张主表和一张关联表。院校表存学校名称、代码、所在地、层次、办学类型、招生批次、备注;专业表存专业名称、代码、所属学科门类、学制、学位;关联表负责把院校和专业做成多对多关系,同时带上该院校该专业当年的招生人数、学费、选考科目要求。
这里有一个容易踩坑的地方:同一所院校在不同省份的招生专业和人数可能不一样。如果你一开始把“专业”当成院校的子表,后面做分省数据就会出现大量冗余。所以数据建模时,“招生计划”这个语义要独立表达,不能简单挂在院校表下。我的做法是关联表里带着省份字段,一条记录就是“某校某专业在某省的招生计划”。
历年分数线单独建表,每一行记录某一所院校在某一年某一批次的最低投档分、平均分、最低位次。这张表是录取概率评估的核心数据源,查询频率很高,索引按院校ID加年份建复合索引。千万注意,分数线表的年份字段要设计成int,不要用varchar,否则排序和比较都很麻烦。
3.3 数据统计与模拟数据填充
管理员后台需要看到一些基础统计:总考生数、各院校填报人数、热门专业Top榜、各批次志愿提交率。这些统计我不建议实时去count,因为数据量大了以后性能不可控。我的做法是统计时用Redis缓存,设置五分钟过期,前端展示时直接读缓存,后台更新数据时主动删缓存,下次请求再重新计算。这个套路简单实用,比引入复杂流式计算框架靠谱得多。
开发阶段还有一个容易忽略的点:系统刚搭好时数据库里没有数据,很多功能测起来很空。我写了一个模拟数据生成器,随机生成几百个考生、上百所院校和几千条分数线。这个模拟数据器非常有用,它可以构造边界情况,比如某个院校招生计划只剩一个名额时,模拟投档能不能正确处理。
4. 数据库建模:核心表设计与初始化SQL
4.1 实体关系梳理
设计数据库时最怕表关系理不清。这个系统相对复杂的关系有两处:一是用户和志愿表的父子关系,二是院校、专业、招生计划之间的多对多关系。梳理下来,核心表有这些:
| 表名 | 说明 |
|---|---|
| t_user | 用户表,存考生、管理员、院校老师账号 |
| t_university | 院校表 |
| t_major | 专业表 |
| t_university_major | 院校专业关联表,含招生计划信息 |
| t_score_line | 历年分数线表 |
| t_application | 志愿表主表 |
| t_application_item | 志愿明细表 |
| t_notice | 公告表 |
| t_favorite | 收藏表 |
有一张表我一开始没建,开发到统计功能时才补上,就是省份批次表。志愿填报是按省分批次执行的,不同省份批次名称不一样。用户表、院校表、批次表这三者需要关联。如果一开始忽略批次维度,后面做模拟投档时就会无从下手,因为规则判断依赖批次归属。
4.2 志愿表与志愿明细表的设计
志愿表主表不用存太多东西,核心是记录谁在什么时候填报了什么批次,以及总体状态。我用几个状态字段控制流程:草稿、已提交、已确认、已失效。考生保存但没提交是草稿;提交之后是已提交;确认后不允许改是已确认。这样设计的好处是状态流转边界清楚,后端做校验时只看状态字段,不用依赖前端传来的隐藏变量。
明细表字段是志愿行的核心,包括院校ID、专业ID、是否服从调剂、志愿顺序号。这里不要直接把院校名称、专业名称冗余存进去,如果院校信息被管理员修改,冗余字段很容易产生不一致。要拿名称时通过关联关系去join,数据量不大时查询性能完全没问题。
4.3 数据库表的初始化SQL参考
用MyBatis-Plus建表时,我习惯先手写SQL建表,再生成实体类,这样字段精度可控。用户表的简化设计如下:
CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT '密码(加密存储)', `real_name` varchar(30) DEFAULT NULL COMMENT '真实姓名', `role_type` tinyint(4) NOT NULL DEFAULT '2' COMMENT '角色 1管理员 2考生 3院校老师', `province_id` bigint(20) DEFAULT NULL COMMENT '所属省份', `exam_score` int(11) DEFAULT NULL COMMENT '高考总分', `rank_num` int(11) DEFAULT NULL COMMENT '全省位次', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';密码字段长度留100,是因为BCrypt加密后的结果比较长,如果留50,以后换加密方案就得改表。real_name独立于username,保证登录账号可以不是真实姓名,也为以后接实名验证留余地。exam_score和rank_num冗余在用户表上,一方面方便展示,另一方面为模拟投档做准备,注意这两个字段要由管理员维护,不能允许考生自己提交,否则操控数据就没意义了。
5. 志愿填报核心逻辑:从校验到模拟投档
5.1 提交前的规则校验
志愿填报最核心的接口是提交志愿表。逻辑复杂度不在于SQL有多难,而在于业务规则多且硬。整理下来,我在提交时做了四层校验。
第一层是基础参数校验,批次、院校数量、专业数量不能为空,防前端绕过。第二层是数量和规则校验,某一批次的志愿数量必须在规定范围内,上限从配置表读取,不要写死在代码里。第三层是去重校验,同一批次下同一个院校不能重复,同一个院校下同一个专业也不能重复。第四层是状态校验,志愿表已经确认过的不能再提交,必须在事务里做乐观锁更新,防止并发情况下重复确认。
这里特别说一下去重校验的坑。前端传过来的院校编码,我一开始直接用equals判断,结果测试时发现同一个院校偶尔会重复出现。排查了半天,发现是编码字段里带了不可见空格,导致equals判断失效。后来我把所有id类字段统一用Long接收,前端传字符串进来时JSON解析会直接转类型,这个问题从源头上规避。
5.2 平行志愿的模拟投档逻辑
如果只是把志愿存进数据库,这个系统只能叫“登记表管理”,不能算“填报系统”。项目需求里有一项是模拟投档,也就是用考生分数和位次去模拟平行志愿投档过程。平行志愿的规则可以概括为八个字:分数优先、遵循志愿、一轮投档。
实现上,入口是一个管理员手动触发的接口。它会把这些考生按分数排序,分数相同的按位次排序,然后从高分考生开始一个个处理。每个考生拿到他的志愿列表,从第一志愿开始检查目标院校是否还有剩余计划。如果院校还有名额,并且具体专业也匹配,就投进去,同时把剩余计划数减一;如果院校满了,就看下一个志愿院校。处理完一个考生,再处理下一个。
算法本身不复杂,真正的坑在执行效率。学生数量上千、院校专业上千时,纯用循环加SQL查询模拟一次要几分钟。我做了优化:把所有院校的剩余计划数预加载到内存的Map里,院校ID做key;考生按分数排好序,一次遍历完成投档。调试时把日志级别调成INFO,能看到每个考生的投档情况,这对最终展示结果非常有帮助。
public void simulateDispatching(List<Student> students, Map<Long, Integer> remainingPlans) { students.sort((s1, s2) -> { int cmp = s2.getScore().compareTo(s1.getScore()); if (cmp != 0) { return cmp; } return s1.getRankNum().compareTo(s2.getRankNum()); }); for (Student student : students) { List<ApplicationItem> items = itemMapper.selectByApplicationId(student.getAppId()); for (ApplicationItem item : items) { Integer remain = remainingPlans.getOrDefault(item.getUniversityId(), 0); if (remain > 0) { remainingPlans.put(item.getUniversityId(), remain - 1); dispatchResultMapper.insert(student.getId(), item.getUniversityId(), item.getMajorId()); break; } } } }这段代码在教学上够用,但没有考虑服从调剂的处理。如果某个考生选的专业都满员了,但院校还有剩余计划,这时需要判断是否服从调剂,如果服从,就把他调剂到同校其他还有名额的专业。实现时“服从调剂”分支很容易和“顺序优先”分支混淆。我单独写了一个测试用例,构造“第一志愿院校专业满员但同校其他专业有空位”的场景,逼着代码把两个分支区分开,这个方法对排查逻辑缺陷很有效。
5.3 录取概率预测的简化方案
考生打开系统,最先看的往往是“我这个分数能不能上这所大学”。这个功能不能只给个百分比糊弄人,需要有可解释的数据支撑。当时我用的是比较保守的方案:取目标院校近三年最低投档位次,和考生当前位次对比。如果考生位次高于该院校三年最低位次的平均值,预测为“较稳妥”;如果介于平均值和中位数之间,预测为“可冲刺”;否则为“风险较大”。
这个方案本质上是经验模型,不够精准,但好处是透明、可解释、实现快。有同学想做得更复杂,引入一分一段表、招生计划变化系数、热度指数,这些后续都能加,但要小心过度设计。我更建议先把基础版本做扎实,把“近三年位次对比”这个功能通过一个接口暴露出来,以后要换算法只改Service里的一个方法就行。
这个功能在数据库上的实现也不复杂,一条分组查询就能拿到三年数据。我唯一提醒的是位次的排序规则:位次数字越小代表排名越靠前,比较时容易搞反,计算“考生位次是否高于院校平均位次”,实际用的是考生rank_num小于平均位次,写SQL时特别容易绕晕。
6. 开发中踩过的坑与排查实录
6.1 配置与依赖兼容问题
Spring Boot版本选择对这类项目影响很大。我一开始图新,用了比较新的版本,结果和某个第三方依赖不兼容,启动直接报错。排查时先看启动日志最上面的错误,再用排除法逐个注释依赖,最后发现是版本冲突。后来学乖了,用一个稳定版本组合,所有依赖版本用Maven的BOM管理,不在子模块里写死版本号,项目启动稳定多了。
还有一个容易忽略的地方是JDBC连接字符串。MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver,如果套用了MySQL 5的配置,项目能启动但连不上数据库,日志报错让你以为配置写错了,实际是驱动类名问题。这个坑我见别人踩过不只一次,写出来提醒一下。
6.2 并发提交与重复数据
志愿填报高峰就在开放的那几个小时,并发量大起来可能出现重复提交。最典型的现象是考生快速点了两次提交,生成了两条志愿记录。
解决办法用了两层。第一层在前端,提交按钮点击后立即进入loading状态并禁用,防止连点。第二层在后端,给志愿表主表增加version字段,提交时用乐观锁更新,UPDATE语句里带上WHERE version = #{oldVersion},影响行数为0就说明版本冲突,直接提示“志愿表已被他人修改,请刷新后再操作”。这两个方案合起来,能挡住绝大多数重复提交场景。
6.3 查询性能的隐藏瓶颈
系统刚联调完成时,院校列表页打开要三秒。我用数据库慢查询日志看了一下,发现是专业列表的SQL关联了三张表,且没走索引。原因是area_id字段在表里是varchar,但查询条件里传的是Integer,MySQL在比较时会把字段类型隐式转换,导致索引失效。后来把字段类型统一成BIGINT,查询时间从3秒降到了80毫秒。这个例子特别能说明问题:索引建了不等于用了,字段类型不匹配会让索引白建。
另一个性能问题是模拟投档接口的SQL循环,我在5.2节已经说了解决方案。这里再补充一个经验:调试性能问题不要靠猜,用慢查询日志配合EXPLAIN看执行计划,基本能定位到95%的问题。
6.4 前后端联调的字段命名问题
后端返回给前端的字段是驼峰命名,比如universityName,前端JSON解析没问题。但有一次我把接口里的返回对象直接写成了数据库实体类,MyBatis-Plus默认开启驼峰映射,看起来没问题,可当我在一个自定义多表查询里用Map接收时,字段名全变成了下划线,前端那边取不到数据。
后来所有返回对象我都统一用VO类组装,不让Map直接返回前端,这个问题从源头上消除。一次联调教会我,接口返回结构要在设计阶段定好,而不是实现阶段看心情。
6.5 部署与打包的细节
部署阶段,我用的方式比较简单:后端打成Jar包,放到一台Linux服务器上运行,前端打包成静态文件,用Nginx做反向代理,同时把/api路径转给后端服务。这里有几个细节值得说。
配置文件不要直接写死在application.yml里,而是把数据库连接、Redis地址这些环境相关的参数放到application-prod.yml,部署时用spring.profiles.active指定。Jar包启动时,我习惯用nohup加输出重定向,把日志写到固定文件,方便排查问题。
Nginx配置里需要特别注意上传大小限制,如果后续要做院校Logo上传,Nginx默认的client_max_body_size可能会拦截大文件。我一开始没配置,前端传图片一直报413,排查了好久才想起这个参数。
写在最后
如果让我重新做一遍这个项目,我会从“规则配置化”再往前走一步。目前志愿数量上限、批次名称、投档模式这些还属于半配置化状态,有一部分写死在枚举里。如果目标使用者是多个省份的招生主管部门,这些规则最好全部挪到配置中心,做成规则引擎的形态,业务人员改配置就能适配新政策,不用重新发版。
开发这类项目习惯我最后再强调一次:业务规则一定要用测试用例固化下来。平行志愿这种逻辑靠人肉验证不可靠,把每一种边界情况写成测试,后续改需求时才不会把已经调好的功能改坏。这个习惯我在好几个项目里都受益,强烈推荐你也试试。