1. 为什么我建议用Java+Vue做高校教务系统
如果你正在纠结毕业设计、课程设计或者求职项目该做什么,"高校教务系统"这个题目我其实是比较推荐的。原因很简单:它不像电商系统那样人人都在做,又比简单的博客系统更能体现业务设计能力和工程化水平。
教务系统覆盖的场景非常真实——学生要选课、查课表、查成绩,教师要录入成绩、查看授课任务,教务管理员要维护课程、排课、管控选课时间,系统管理员还要管用户、管角色、管日志。这些业务之间是有严格逻辑约束的,比如选课之前得有开课计划,成绩录入之后学生才能看到成绩,退课不能超过截止时间。这种"业务规则驱动的系统",比单纯增删改查更能看出一个开发者的水平。
技术栈上,Java + Vue的组合在今天依然是前后端分离项目的主流配置。Spring Boot负责后端接口,MyBatis Plus操作MySQL,前端用Vue 3 + Element Plus搭管理后台,这套组合的资料多、问题排查容易、模板也多,几乎不会把你卡死在环境问题上。更重要的是,这套技术栈和大部分公司Java Web岗位的要求是重合的,做完这个项目,简历上可以写的东西也非常扎实。
我按"源码 + 数据库 + 文档"的完整交付思路把整个项目拆分一遍,里面包含完整的表结构设计、后端核心接口代码、前端关键配置和部署过程,尽量做到直接照着做就能跑起来。
2. 教务系统的核心业务流程与功能模块拆解
2.1 四种角色的权限边界
教务系统最核心的不是CRUD,而是角色与业务规则。我采用的是四类角色:学生、教师、教务管理员、系统管理员。
| 角色 | 核心权限 | 典型操作 |
|---|---|---|
| 学生 | 选课、退课、查看课表、查看成绩 | 在选课时间内选课,退课后释放学分占用量 |
| 教师 | 查看授课任务、录入成绩、查看学生名单 | 期末录入成绩,可暂存并在发布前修改 |
| 教务管理员 | 维护课程、发布开课计划、设置选课时间、院系班级管理 | 创建学期开课计划,控制选课窗口 |
| 系统管理员 | 用户管理、角色分配、数据统计 | 重置密码、冻结账号、查看操作日志 |
这个设计里有一个关键点:用户登录不直接面对业务表,而是通过角色表关联到具体业务实体。也就是说,登录认证拿到的是"这个人是谁、属于什么角色",如果要判断"他能否录入这门课的成绩",后端接口里必须再校验一次教师与课程的归属关系。光靠前端隐藏按钮是不够的,因为接口本身可以被直接调用。
2.2 四条核心业务链路
教务系统的业务闭环其实可以压缩成四条链路:
链路一:基础数据准备。院系、专业、班级、教师信息由管理员维护好,学生账号可以批量导入。课程主数据(课程编号、名称、学时、学分、考核方式)也在这个阶段完成。
链路二:开课与排课。每个学期开始前,教务管理员从课程主数据里选择本学期要开的课程,形成"开课计划"。开课计划里包含授课教师、上课时间、上课地点、选课容量、选课时间窗口。这里一定要区分"课程"和"开课计划"两个概念:课程是静态的基础数据,开课计划才是学期实例。
链路三:学生选课。学生在选课时间窗口内浏览可选的课程,提交选课请求。系统需要实时扣减剩余名额,并且校验学分上限、上课时间冲突。
链路四:成绩流转。学期结束,教师录入成绩。成绩有"草稿"和"正式发布"两个状态,发布之前学生不可见,发布之后学生才能查询。学生查到的最终成绩直接影响到绩点计算。
2.3 状态机设计是业务稳定性的核心
我在文档里专门画了一张状态流转表:
| 业务对象 | 状态 | 流转条件 |
|---|---|---|
| 学期 | 未开始 / 进行中 / 已结束 | 管理员手动切换 |
| 选课窗口 | 未开放 / 选课中 / 已关闭 | 按设定时间自动切换 |
| 开课计划 | 草稿 / 已发布 / 已归档 | 发布后学生可见 |
| 成绩记录 | 草稿 / 已发布 | 教师发布后锁定 |
| 学生选课记录 | 已选 / 已退 | 退课时间截止后不可退 |
这些状态不是写死在代码里的字符串,而是用一个字段维护,所有的状态流转只允许走对应的接口。比如选课接口只能作用于"选课中"的开课计划,成绩修改接口只能修改"未发布"的成绩记录。把状态机想清楚,后面写接口会省掉大量if-else。
3. 从零设计数据库:教务系统的表结构到底该怎么拆
3.1 核心表清单与字段说明
数据库是整个项目最值得看的部分,因为教务系统的表关系比普通管理系统复杂得多。我的表结构设计如下,你可以直接照搬建库:
| 表名 | 说明 | 核心字段 |
|---|---|---|
sys_user | 用户表 | id, username, password, role_id, status, create_time |
sys_role | 角色表 | id, role_name, remark |
college | 院系表 | id, college_name, dean |
major | 专业表 | id, college_id, major_name |
class_info | 班级表 | id, major_id, class_name, grade |
student | 学生表 | id, user_id, student_no, name, class_id, phone |
teacher | 教师表 | id, user_id, teacher_no, name, college_id, title |
course | 课程主数据 | id, course_code, course_name, credit, hours, exam_type |
teaching_plan | 开课计划表 | id, course_id, teacher_id, semester, max_students, status, select_start_time, select_end_time |
course_selection | 学生选课表 | id, student_id, plan_id, selected_time, status, score_id |
score | 成绩表 | id, course_selection_id, score, gpa, status, create_time |
核心的关联关系是:sys_user只负责认证,学生和教师通过user_id关联到具体的业务表。这样处理的好处是,教师身份和学生身份的校验都在业务层完成,用户表本身很干净。
3.2 课程与开课计划的拆分逻辑
很多刚接触教务系统的人会直接把课程和开课计划合成一张表,这是一个很容易踩的坑。课程是稳定的,比如"高等数学A",它拥有固定的学分、学时、课程代码,但不会绑定学期和老师。每个学期开什么课、谁来上、在哪个教室、什么时候选课,这些信息属于开课计划。
如果合成一张表,会出现两个问题:一是同一个课程在多个学期开设时数据冗余;二是课程信息变更(比如学时调整)会污染历史学期的上课记录。所以在course和teaching_plan之间是"一对多"的关系,course_selection选的是开课计划,而不是课程本身。这个设计帮我排掉了后续几乎所有数据不一致的隐患。
3.3 选课表与成绩表的关联约束
选课表和成绩表之间的设计也值得单独说。我采用的是"选课记录 + 成绩记录"两张表,score.course_selection_id指向选课记录,并加了唯一约束。也就是说,一个学生的一门选课只能对应一条成绩,不会出现重复成绩记录。
选课表本身要做的约束有两个:
- 同一个学生在同一学期的同一门开课计划只能选一次,用
(student_id, plan_id)做唯一索引。 - 选课名额的扣减要放在事务里完成,先查剩余名额,再判断,最后更新剩余人数。
这里有一个我在文档里特别标注的经验:数据库的唯一约束是防止"重复选课"的最后一道防线,业务代码里的判断只是第一道防线。因为并发环境下,两个请求同时查到"剩余名额=1",如果不加唯一约束,两个人都有可能选上,名额就超了。加了唯一索引,第二次插入会直接报错,事务回滚,名额不会出问题。
4. 后端核心接口实现:登录、选课、成绩录入的完整代码
4.1 JWT登录鉴权与用户上下文传递
登录模块我用的是JWT,不用Session。原因有两个:一是前后端分离项目,前端要跨端口访问后端接口,Session的Cookie处理比较麻烦;二是JWT本身携带用户标识,接口层面拿到Token就能解析出用户身份,不需要每次查Redis。
后端LoginController的核心逻辑是这样的:
@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private IUserService userService; @Resource private JwtUtil jwtUtil; @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { User user = userService.lambdaQuery() .eq(User::getUsername, dto.getUsername()) .one(); if (user == null || !PasswordUtil.matches(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() != 1) { return Result.error("账号已被禁用"); } String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRoleId()); return Result.ok(new LoginVO(token, user.getUsername(), user.getRoleId())); } }密码加密我用的是BCrypt,不是MD5。BCrypt每次加密结果不同,即使两个用户密码相同,存储的密文也不同,可以有效抵抗彩虹表攻击。项目文档里一定要写清楚这一点,因为它是面试官很爱问的问题。
登录成功后,后续接口通过拦截器解析JWT。我使用ThreadLocal保存当前登录用户的信息,业务代码里随时可以拿到:
public class UserContext { private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }ThreadLocal的坑在于线程复用,所以一定要在请求完成后调用clear(),否则下一次请求可能取到上一个用户的信息。我在拦截器的afterCompletion里统一清理。
4.2 选课接口:并发条件下的乐观锁与事务控制
选课接口是这个项目里最有含金量的部分。学生选课时,后端要同时做四件事:
- 校验选课窗口是否开放,比对当前时间和
teaching_plan.select_start_time / select_end_time。 - 校验该学生是否已经选过这门课,通过唯一索引兜底。
- 校验该学生本学期已选学分 + 本门课程学分是否超过上限(比如30学分)。
- 扣减开课计划的剩余名额。
核心代码如下:
@Transactional(rollbackFor = Exception.class) public Result selectCourse(Long studentId, Long planId) { TeachingPlan plan = teachingPlanMapper.selectById(planId); LocalDateTime now = LocalDateTime.now(); if (now.isBefore(plan.getSelectStartTime()) || now.isAfter(plan.getSelectEndTime())) { return Result.error("当前不在选课时间范围内"); } if (plan.getSelectedCount() >= plan.getMaxStudents()) { return Result.error("课程名额已满"); } Long count = courseSelectionMapper.selectCount( new LambdaQueryWrapper<CourseSelection>() .eq(CourseSelection::getStudentId, studentId) .eq(CourseSelection::getPlanId, planId)); if (count > 0) { return Result.error("您已选修过该课程"); } int rows = teachingPlanMapper.updateSelectedCount(planId); if (rows == 0) { return Result.error("课程名额已满,请刷新后重试"); } CourseSelection selection = new CourseSelection(); selection.setStudentId(studentId); selection.setPlanId(planId); selection.setStatus(0); selection.setSelectedTime(now); courseSelectionMapper.insert(selection); return Result.ok("选课成功"); }这里有几个细节值得展开。
第一,@Transactional是必须加的,因为"扣减名额"和"插入选课记录"必须同时成功或同时失败。如果扣减了名额但选课记录插入失败,事务回滚后名额会自动恢复。
第二,updateSelectedCount这个SQL不是普通的update teaching_plan set selected_count = selected_count + 1 where id = ?,而是额外带了一个条件:
UPDATE teaching_plan SET selected_count = selected_count + 1 WHERE id = #{planId} AND selected_count < max_students这种写法的好处是:即使两个请求并发到达,数据库行锁也会保证只有第一个请求能更新成功,第二个请求的rows会等于0,从而避免超卖。这是比"先查再更新"可靠得多的方案,因为这个方案把校验和更新合并成了一个原子操作。
4.3 成绩录入与发布的状态流转
成绩模块我设计了两个接口:保存草稿和正式发布。教师在前端可以反复修改草稿,但发布后就不能再改了。如果成绩真的录错了,需要教务管理员拥有一个"撤销发布"的权限。
@PostMapping("/score/saveDraft") public Result saveDraft(@RequestBody ScoreDTO dto, LoginUser user) { TeachingPlan plan = teachingPlanMapper.selectById(dto.getPlanId()); if (!plan.getTeacherId().equals(user.getTeacherId())) { return Result.error("只能录入本人授课课程的成绩"); } Score score = scoreMapper.selectOne( new LambdaQueryWrapper<Score>() .eq(Score::getCourseSelectionId, dto.getCourseSelectionId())); if (score != null && score.getStatus() == 1) { return Result.error("成绩已发布,不可修改"); } if (score == null) { score = new Score(); score.setCourseSelectionId(dto.getCourseSelectionId()); } score.setScore(dto.getScore()); score.setStatus(0); score.setGpa(calculateGpa(dto.getScore())); scoreMapper.insertOrUpdate(score); return Result.ok(); }这个接口里最关键的是"教师归属校验"。接口是暴露给所有登录教师的,但一个教师只能操作自己授课的开课计划。如果你只靠前端隐藏按钮来限制,实际接口直接调用就能绕过权限。所以每个涉及教师操作的接口,都要在Service层做一次plan.getTeacherId().equals(currentUser.getId())的校验。
GPA计算我这边用的是最常见的四分制:
private double calculateGpa(Integer score) { if (score >= 90) return 4.0; if (score >= 85) return 3.7; if (score >= 82) return 3.3; if (score >= 78) return 3.0; if (score >= 75) return 2.7; if (score >= 72) return 2.3; if (score >= 68) return 2.0; if (score >= 64) return 1.5; if (score >= 60) return 1.0; return 0.0; }这套换算规则不同学校可能不一样,建议把规则写进系统配置表,而不是写死在代码里。
5. 前端Vue实现的关键细节:路由守卫、权限控制和接口封装
5.1 前端技术栈与目录结构
前端我用的是Vue 3 + Vite + Pinia + Element Plus + Axios。这套组合对于管理后台类项目非常成熟,Element Plus自带的表格、表单、弹窗组件可以大幅提高开发效率。
推荐的前端目录结构:
src/ api/ // 接口请求封装 assets/ // 静态资源 components/ // 通用组件 layout/ // 后台布局 router/ // 路由配置 stores/ // Pinia状态存储 utils/ // 工具函数 views/ student/ // 学生端页面 teacher/ // 教师端页面 admin/ // 教务管理端页面5.2 axios拦截器与Token注入
接口请求统一走axios封装,请求拦截器注入Token,响应拦截器统一处理业务错误和登录过期:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( res => { const { code, message, data } = res.data if (code === 200) { return data } ElMessage.error(message) return Promise.reject(new Error(message)) }, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.error('登录已过期,请重新登录') } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(err) } )这里有一个经验:登录过期判断不要直接放在axios里处理,要后端接口主动返回401状态码,前端响应拦截器统一跳转登录页。如果后端返回的是业务错误码而不是HTTP状态码,前端就要写额外的判断逻辑,容易漏。
5.3 路由守卫与动态菜单
教务系统有四种角色,每种角色的菜单不同。我采用"动态路由"方案:登录成功时后端返回角色对应的菜单权限列表,前端用router.addRoute动态注册路由。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') return } if (token && to.path === '/login') { next('/') return } next() })角色权限的校验建议放在每个路由的meta字段里:
{ path: '/admin/course', component: () => import('@/views/admin/CourseManage.vue'), meta: { roles: ['admin'], title: '课程管理' } }路由守卫里再校验一次当前角色的meta:
if (to.meta.roles && !to.meta.roles.includes(store.userInfo.roleId)) { next('/403') return }侧边栏菜单就可以根据路由表自动生成,不用单独维护一份菜单数据,减少前后端不一致的问题。
5.4 开发环境跨域配置
前后端分离开发时,前端跑在5173端口,后端跑在8080端口,直接请求必然跨域。我在vite.config.js里配置了代理:
export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端代码里请求/api/auth/login,开发环境下会被代理转发到后端的http://localhost:8080/api/auth/login。生产环境下,Nginx再配置一次同样的转发,前后端代码里就不需要写死域名了。
6. 部署到Linux服务器的完整过程与常见报错
6.1 后端打包与前端构建
后端我用Maven打包,命令很简单:
mvn clean package -DskipTests打包后会生成target/xxx.jar,这个jar内嵌了Tomcat,可以直接运行:
nohup java -jar educational-admin.jar --spring.profiles.active=prod > app.log 2>&1 &前端构建:
npm run build生成dist目录,配置Nginx指向它,同时把/api反代到后端的8080端口:
server { listen 80; server_name yourdomain.com; location / { root /var/www/edu-admin/dist; index index.html; try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }前端项目是Vue Router的history模式,刷新页面时会走路由而非真实文件路径,所有必须配置try_files $uri $uri/ /index.html,否则刷新页面会出现404。
6.2 我在部署过程中踩过的坑
第一个坑是MySQL 8的密码加密方式。MySQL 8默认用caching_sha2_password,而项目里的MySQL驱动版本如果太旧(5.x),会报Public Key Retrieval is not allowed。解决办法有两种:一是升级MySQL驱动到8.x,二是在JDBC连接串上加allowPublicKeyRetrieval=true&useSSL=false。我推荐直接用新驱动。
第二个坑是文件上传路径。如果在服务器上需要保存导入Excel模板、学生头像之类的文件,不要用相对路径,否则项目重启后文件容易丢失。我始终将文件路径配置为服务器绝对路径,并放在项目外部目录,如/data/edu-admin/upload/。
第三个坑是前端npm install经常失败。建议使用pnpm或者配置淘宝镜像:
npm config set registry https://registry.npmmirror.com还有一次遇到构建时内存溢出,报JavaScript heap out of memory,解决方案是修改Node的堆内存:
NODE_OPTIONS="--max-old-space-size=4096" npm run build6.3 数据库脚本的初始化顺序
项目自带的数据库脚本是我手动整理好的,执行顺序很重要:先建数据库,再建表,最后插入初始化数据。SQL脚本里我加了DROP TABLE IF EXISTS语句,方便重复执行。
初始化数据包括:院系列表、专业列表、班级列表、管理员账号、教务管理员账号。学生和教师账号则建议通过接口用Excel批量导入,不要在SQL脚本里硬编码一堆假用户。
有一点需要特别提醒:密码字段在SQL脚本里存入的必须是BCrypt密文,不能是明文。脚本里的默认密码可以统一生成一次BCrypt密文,然后写死在INSERT语句里。否则第一次登录登录成功但后续比对密码会一直失败。
7. 项目试运行的真实体验与后续可扩展方向
项目做完之后,我在自己本地的测试环境跑了两周,期间模拟了各种真实场景。最典型的是选课高峰期:我用Jmeter模拟100个学生同时选同一门容量为50人的课程,最终数据库里只有50条成功记录,其余全部被"名额已满"拦截,没有出现超卖和重复选课。这说明前面设计的乐观锁方案是有效的。
实际运行中也有一些原来没考虑到的问题。比如学生选课成功之后,突然不想上了,退课再选别的课,这个流程看似简单,但要额外处理"是否已超过退课截止时间"、退课后名额是否立即恢复"等问题。我在退课接口里加了时间判断,超过了教务管理员设定的退课截止时间,就只能走线下申请流程。
还有一个体会:教务系统的报表统计往往被低估,但其实很常见。比如按课程统计选课人数、按院系统计不及格率、按教师统计平均分,这些都是教务员日常要用的功能。这些数据用SQL分组聚合很容易查出来,前端再用ECharts画成图表,整个项目的展示效果会提升一个档次。
后续如果要继续扩展,可以考虑这几个方向:
- 导入导出功能:学生名单Excel导入、成绩单Excel导出、课表PDF导出。
- 自动排课算法:基于教室容量、教师时间、班级时间做冲突检测和自动分配。
- 消息通知:选课开放提醒、成绩发布提醒,用WebSocket或者站内信实现。
- 在线评教:学生对课程和教师进行匿名评价,统计结果供教务参考。
- 多学期数据归档:历史学期数据单独库或分表存储,避免单表数据过大影响查询性能。
我个人比较推荐先做导入导出和消息通知,因为这两个功能是教务日常使用中最能感知到价值的,而且实现难度可控,适合作为项目的第二个迭代版本。
回头来看,这个项目能稳定跑起来,最大的功臣其实是前期把业务规则想清楚了。表结构设计、状态机设计、选课并发控制、教师归属校验,这些问题只要有一个没想透,跑起来之后就是各种bug。如果你正在做类似的系统,我建议先别急着写代码,拿出一张纸,把角色、流程、状态给画清楚,后面会顺很多。