每个学计算机的同学,基本都绕不过一个叫学生管理系统的项目。不管是课程设计、期末大作业还是毕业设计,它出现的频率高到离谱。网上相关的代码、教程、毕设源码一抓一大把,但大多数要么是十几年前 JSP 的老古董,要么就是只贴核心代码、不讲为什么的"半成品"。也有人管它叫学生选课管理系统,其实本质上是一套东西——在学生基础信息管理之上,加了选课、成绩这些业务闭环,算是对管理类系统的一个完整覆盖。
我前后做过两版学生管理系统,第一版是纯 Servlet + JSP,第二版换成了 Spring Boot + Vue 前后端分离。说实话,这个项目虽然看起来"烂大街",但把它做好、做透,能覆盖到的东西远比你想象的多:登录鉴权、角色权限、CRUD、事务控制、一对多多对多关系映射、前后端联调、跨域处理……都是真实业务里天天要用的东西。
这篇就把我做这套系统从需求拆解到部署上线的完整过程捋一遍,重点说清楚每一步是怎么想、怎么选、怎么踩坑又怎么填坑的。打算做学生管理系统当课设或者毕设的,可以直接照着这个思路来,比单纯复制一堆源代码有用得多。
1. 学生管理系统到底要管什么:先把需求拆明白
很多人拿到"学生管理系统"这个题目,第一反应是打开 IDEA 建工程、建表、写代码。我第一版也这样干过,结果就是做到一半发现角色搞混了、业务重叠了、重要功能漏了,回头重构折腾到怀疑人生。做管理系统这类项目,第一步永远是需求拆解,把"老师让你做个学生管理系统"这句话翻译成能落地的功能清单。
1.1 从一句话到功能模块:三种角色划分出边界
学生管理系统一般要管三类人:学生、教师、管理员。做需求分析的时候,最实用的方法就是把这三种角色的操作权限列出来,别让功能交叉。
管理员:
- 管理学生信息(增删改查、导入导出)
- 管理教师信息(增删改查)
- 管理课程信息(创建课程、设置容量、指定任课教师)
- 查看所有选课记录和成绩数据
教师:
- 查看自己名下课程的选课学生名单
- 给学生录入成绩、修改成绩
- 查看课程的基本统计信息(选课人数、成绩分布)
学生:
- 查看可选的课程列表
- 选课 / 退课
- 查看自己的已选课程和成绩
这个功能清单就是整个系统的骨架。后面所有的表结构设计、接口设计、页面设计,都是围绕这三类角色的操作需求展开的。
1.2 核心业务闭环:选课不是"插入一条记录"那么简单
如果只有学生信息的增删改查,那这个项目做出来就只是个"电子 Excel",没什么含金量。选课和成绩录入才是这个系统的业务核心,也是评委老师最关注的部分。
选课的完整流程是这样的:
- 学生登录系统,进入选课页面
- 页面展示当前学期可选课程,包括课程名称、学分、任课教师、上课时间、容量和已选人数
- 学生点击选课,系统做一系列校验:是否已选过这门课、课程是否已满、上课时间是否和已选课程冲突
- 校验通过,生成选课记录,同时课程已选人数加 1
- 学生可以在"我的课表"里查看或者退课
成绩录入流程更像个审批流:教师登录后只能看到自己教的课程,选择一门课之后看到选课学生列表,然后录入成绩。学生端只能查看自己的成绩,不能看别人的。
1.3 功能优先级排序:课设时间紧,先做主干再做枝节
我在第一版的时候什么功能都想加,结果主流程反而做得不扎实。第二版学乖了,先把功能按优先级分成三档:
P0(必做):登录认证、学生管理 CRUD、课程管理 CRUD、选课/退课、成绩录入/查看P1(建议做):分页搜索、表单校验、按角色动态显示菜单、数据统计(选课人数、平均分)P2(有余力做):Excel 导入导出、修改密码、头像上传、页面主题切换
实际做题的时候,把 P0 做扎实,再挑一两个 P1 做完,就已经超过大部分人的完成度了。别一上来就想着做个人中心、消息通知这种边角功能,核心流程没跑通,花里胡哨的东西反而显得虚。
2. 技术选型:Spring Boot + Vue 前后端分离这套组合的逻辑
技术选型这事,问十个人有十种答案。我第二版用的是Spring Boot + Vue3 + MySQL这套前后端分离方案,也是目前课设和毕设里最主流的搭配,没有之一。
2.1 为什么我放弃 Servlet + JSP,改用前后端分离
第一版用的 JSP,当时觉得在同一个文件里写 HTML 和 Java 挺方便,项目也能跑。但当页面多了之后问题就来了:JSP 页面里的 Java 代码、标签库和 HTML 混在一起,改一个前端样式要在 IDE 和浏览器之间反复切,调试体验很糟。
关键问题是 JSP 方案在真实企业里已经基本见不到了。做课设虽然说不一定要追新,但如果你的目标是找工作,写着 JSP 的经历在简历上是减分项。前后端分离架构下,前端把接口调通、后端把数据返对,两边可以并行开发。而且部署方式也更符合现代项目的习惯:前端打包成静态文件扔给 Nginx,后端打成 jar 包独立运行。
2.2 后端技术栈:Spring Boot + MyBatis-Plus 是省时间的关键
后端我用的组合:
- Spring Boot 2.7:快速搭起 Web 服务,不用像 SSM 时代配置一堆 XML
- MyBatis-Plus:不是 MyBatis,而是 MyBatis 的增强版,单表 CRUD 几乎不用写 SQL
- MySQL 8.0:数据存储
- JJWT:生成和校验 JWT Token
- Lombok:省去写 Getter/Setter 的时间
选 MyBatis-Plus 的理由很直接:学生管理系统的核心操作就是单表 CRUD,MyBatis-Plus 的BaseMapper直接封装了insert、updateById、selectPage这些常用方法。复杂的多表查询(比如查学生选课列表关联课程信息)再自己写 SQL。这样既快又不失灵活性。
不推荐直接用 JPA/Hibernate,不是说它不好,而是管理类系统的查询条件经常动态变化,JPA 的复杂查询写起来没有 MyBatis 系列直白。
2.3 前端技术栈:Vue3 + Element Plus 让页面"看起来专业"
前端用的是:
- Vue 3+Vite
- Element Plus:UI 组件库,表格、表单、弹窗、分页、消息提示都有现成组件
- Pinia:状态管理,用来存用户信息、登录状态
- Axios:发 HTTP 请求
- Vue Router:前端路由
选 Vue 而不用 React,理由很朴素:国内课设和大部分中小型管理后台都是 Vue 生态,中文资料多,遇到问题容易搜到答案。Element Plus 的表格和表单组件成熟度很高,只要数据格式对,页面效果立刻就有了,不用自己在样式上抠细节。
2.4 给基础薄弱的同学一句实话
如果你 Java 基础和前端基础都比较薄弱,时间又紧,我的建议也是这套组合,但不要强行理解每一个源码细节。先把"请求打过来 - Controller 接收 - Service 处理 - Mapper 查库 - 返回 JSON - 前端渲染"这条链路跑通,中间不懂的地方先抄下来,之后再回头补。学生管理系统是拿来练手的,不是拿来研究框架源码的,能独立跑通、讲清楚每个环节是干嘛的,就已经达到目的了。
3. 数据库设计:八张核心表怎么撑起整个选课闭环
数据库设计是学生管理系统里最重要的一步。表结构设计得好,后面代码写起来顺手,查询也快;设计歪了,后面不是冗余就是缺字段,改起来牵一发动全身。
3.1 核心表结构一览
我第二版一共设计了六张业务表加一张用户表,总共七张。把字段和用途列一下:
| 表名 | 核心字段 | 用途 |
|---|---|---|
sys_user | id、username、password、role、user_ref_id | 统一登录账号,role 区分管理员/教师/学生 |
student | id、student_no、name、gender、class_name、phone、email、enroll_year | 学生详细信息 |
teacher | id、teacher_no、name、title、department、phone、email | 教师详细信息 |
course | id、course_no、course_name、credit、teacher_id、capacity、selected_count、class_time、location | 课程基础信息和选课容量 |
enrollment | id、student_id、course_id、semester、status、create_time | 选课记录 |
score | id、enrollment_id、score、remark | 成绩记录,与选课记录一对一 |
你可能会问为什么要单独一张sys_user表,而不是直接给学生表加个密码字段。原因在于登录账号和业务数据最好解耦。如果学生表里放密码,那么查询学生列表时每次都把密码字段查出来,容易出安全问题。拆出来之后,业务表和登录表各自管各自的事,也方便以后扩展教师、管理员的登录逻辑。
user_ref_id字段存的是这个账号关联的具体业务记录 ID,比如学号为 2024001 的学生,登录账号是stu2024001,user_ref_id就指向student表里对应记录的 id。这样登录之后可以很快查到用户详细信息,也能校验角色和数据权限。
3.2 选课表和成绩表的关系:一对一是最简单可靠的方案
enrollment和score表的关系,设计时有两条路:
一条路是把成绩字段直接放在enrollment表里,选课记录带上score字段,没录入成绩就为空。另一条路是单独拆一张score表,通过enrollment_id关联。
我选了单独拆表。原因有两个:一是职责更清晰,选课表管"学生选了哪些课",成绩表管"这些课考了多少分";二是以后如果要扩展成绩的字段(比如补考成绩、绩点、平时分/期末分),不会污染选课表结构。
不过要注意,单独拆表之后,查询成绩列表时就得做连表查询:score表关联enrollment表拿学生和课程信息,再关联课程表拿课程名。SQL 会复杂一点,但逻辑清楚。
3.3 三个关键设计决策:联合唯一索引、逻辑删除、容量字段
第一个关键决策是在enrollment表上建联合唯一索引:
ALTER TABLE enrollment ADD UNIQUE KEY uk_student_course (student_id, course_id, semester);这个索引的意义是:在同一个学期里,一个学生只能选同一门课一次。哪怕代码里忘了判断是否已选课,数据库层面也会拦截重复插入,这是最后一道保险。我在实际测试的时候就遇到过前端疯狂点击选课按钮、后端重复插入的情况,靠这个唯一索引兜底才没出大问题。
第二个是逻辑删除。学生信息可能录入错了要删掉,但直接 DELETE 会把关联的选课记录也搞出问题(外键约束或者孤儿数据)。我的做法是给表加一个deleted字段,默认 0,删除时改成 1,查询时统一过滤WHERE deleted = 0。这个方案在 MyBatis-Plus 里有现成的@TableLogic注解,加在字段上,删除操作自动变成逻辑删除,查询自动过滤,不用自己写。
第三个是课程容量字段。course表里有capacity(总容量)和selected_count(已选人数)两个字段。选课时要做两步操作:判断selected_count < capacity,然后selected_count + 1。这个逻辑后面在讲选课接口的并发控制时会细说,这里先记住一个原则:已选人数要以数据库字段为准,不要临时去COUNT(*)选课表,因为那个开销会随着数据量增长而变大。
3.4 日期和时间字段的坑:用对类型比你想的重要
学生信息里有入学年份,课程表里有上课时间,选课表里有创建时间。这些字段的类型选择有个小讲究:
- 纯日期(如入学年份)用
DATE类型 - 日期时间(如选课创建时间)用
DATETIME类型 - 不要用
VARCHAR存日期
我之前见过有人用字符串存日期,结果要排序、要比较大小的时候全乱套。MySQL 里的DATETIME能直接按时间排序、范围查询,省心太多。还有时区问题:连接 MySQL 的 URL 里最好明确指定serverTimezone=Asia/Shanghai,不然后端传的日期和数据库存的日期对不上,前后端联调时会非常头疼。这个后面专门讲。
4. 后端开发的几个关键节点:鉴权、CRUD、选课事务
数据库设计好了,后端开发就进入正题了。我不打算把每个接口的代码都贴出来,那样篇幅太长也没必要。挑几个最核心、最体现水平的节点讲透:登录鉴权、分页 CRUD、选课接口的事务与并发控制、成绩录入的权限校验。
4.1 JWT 登录鉴权:从发 Token 到拦截器校验的完整链路
登录接口的逻辑很简单:接收用户名密码,查sys_user表,校验密码(这里建议用 BCrypt 加密存储,不要明文存,哪怕只是课设也要养成习惯),通过后生成一个 JWT 返回给前端。
JWT 的核心是一个加密签名的字符串,里面可以放用户 ID、用户名、角色这些信息。我用的 JJWT 库生成 Token:
String token = Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact();Token 生成的细节不复杂,真正容易出问题的是"如何让每个接口都能识别当前用户"。我加了一个拦截器,拦截除登录接口外的所有请求,从请求头拿Authorization,解析 Token,把用户信息放进 ThreadLocal。业务层想获取当前用户,直接从 ThreadLocal 取。
后端接口权限控制我用了一个比较原始但有效的方式:写一个@RequireRole("ADMIN")之类的注解,加在 Controller 方法上,拦截器里判断当前用户的角色是否有权限访问。比如选课接口必须学生角色,成绩录入接口必须教师角色,学生管理接口必须管理员角色。这个模式简单可靠,不比引入 Spring Security 差多少,而且更容易讲清楚原理。
4.2 学生信息分页查询:动手前想清楚搜索条件
学生管理的核心页面是列表页。列表页几乎一定有分页,有搜索条件。搜索条件可能有:姓名关键字、学号、班级、入学年份。每个条件都可选可不选。
MyBatis-Plus 的分页查询写起来非常简单:
Page<Student> page = new Page<>(current, size); LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Student::getName, keyword) .eq(StringUtils.hasText(studentNo), Student::getStudentNo, studentNo) .eq(StringUtils.hasText(className), Student::getClassName, className) .orderByDesc(Student::getCreateTime); studentMapper.selectPage(page, wrapper);like和eq方法第一个参数是 boolean,条件是 true 才拼接到 SQL 上。这样搜索条件有没有值,都不用写 if 判断,代码很干净。
这里有个容易漏的细节:班级、院系这些字段,能用精准匹配的不要用模糊查询。比如按班级筛选,eq("2024001")就比like("%1%")准确得多。模糊查询只用在姓名这种真正需要"包含"语义的字段上。
分页接口返回的 JSON 结构要统一,我用的格式是:{ total: 100, records: [ ... ] },前端拿到这个结构,Element Plus 的el-table加el-pagination组件,几行代码就能接上。
4.3 选课接口:事务、条件更新和时间冲突一个都不能少
选课接口是整个后端里含金量最高的一个,它涉及事务控制和并发安全,能体现出你和其他人拉开差距的地方。
先看代码逻辑:
@Transactional(rollbackFor = Exception.class) public void enroll(Long studentId, Long courseId) { // 1. 查课程 Course course = courseMapper.selectById(courseId); if (course == null) { throw new BusinessException("课程不存在"); } // 2. 判断容量 if (course.getSelectedCount() >= course.getCapacity()) { throw new BusinessException("课程已满"); } // 3. 判断是否已选 Long count = enrollmentMapper.selectCount( new LambdaQueryWrapper<Enrollment>() .eq(Enrollment::getStudentId, studentId) .eq(Enrollment::getCourseId, courseId) .eq(Enrollment::getSemester, currentSemester) ); if (count > 0) { throw new BusinessException("你已经选过这门课了"); } // 4. 判断上课时间冲突 List<Enrollment> enrollments = enrollmentMapper.selectList(...); for (Enrollment e : enrollments) { Course selectedCourse = courseMapper.selectById(e.getCourseId()); if (timeConflict(course.getClassTime(), selectedCourse.getClassTime())) { throw new BusinessException("上课时间与" + selectedCourse.getCourseName() + "冲突"); } } // 5. 插入选课记录 Enrollment enrollment = new Enrollment(); enrollment.setStudentId(studentId); enrollment.setCourseId(courseId); enrollment.setSemester(currentSemester); enrollmentMapper.insert(enrollment); // 6. 课程已选人数 +1 courseMapper.increaseSelectedCount(courseId); }几个关键点在代码里体现不出来,需要单独说:
事务:上面的步骤 1 到 6 必须在一整件事里,中间任何一步抛出异常,前面的操作都要回滚。比如插入了选课记录,但更新人数失败,那选课记录也得删掉,不然就数据不一致了。@Transactional(rollbackFor = Exception.class)是必须的。
容量判断的并发安全:如果两个学生同时选同一门只剩 1 个位置的课程,按照上面的代码逻辑,第 2 步两个请求都判断"还没满",然后都插入选课记录,结果超卖了。解决办法是第 6 步用条件更新:
UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < capacity如果这条 SQL 影响行数为 0,说明课程已经满了,整体抛出异常回滚。这个方案比用SELECT ... FOR UPDATE行锁更轻量,也更符合实际业务的并发量级。
时间冲突判断:课程表里class_time字段我存的是字符串,比如"周一 3-4 节",判断冲突时要把星期和节次解析出来对比。这里没有用复杂的数据库查询,而是把学生已选课程取出来,在 Java 内存里逐个比对。原因很简单:这个系统里一个学生一学期最多选十几门课,内存比对性能完全够,比写复杂的 SQL 判断逻辑可维护性强得多。
4.4 成绩录入的权限细节:教师只能改自己课程的成绩
成绩录入接口的坑不在技术,在权限。如果只做"登录了就能录入成绩",那学生登录系统之后直接调接口就能给自己改分了,这不行。
我的做法是在成绩录入接口里先查课程:
Course course = courseMapper.selectById(enrollment.getCourseId()); if (!course.getTeacherId().equals(currentTeacherId)) { throw new BusinessException("只能录入自己课程的成绩"); }也就是说,权限判断不能只看"你是不是教师",还要看"这门课是不是你的"。这种数据归属的校验,在真实系统里叫行级权限。很多人的课设就是在这里翻的车——接口能调通,但权限漏洞百出。做管理系统,写接口前多问一句:这个操作的数据归谁管,谁有权动它。
5. 前端页面落地:Vue3 + Element Plus 的常见写法与坑
前端这部分,我用 Vue3 + Element Plus 搭了一套管理后台。Vite 创建项目、安装 Element Plus 这些步骤就不啰嗦了,网上遍地都是。重点讲几个我实际开发中觉得最值得注意的点。
5.1 路由守卫和菜单权限:按角色控制能看到的页面
前端路由分成三类:公开路由(登录页、注册页)、学生路由(选课页、我的课表)、管理员路由(学生管理、教师管理、课程管理)。不能让学生访问管理员的页面,也不能让管理员去选课。
最简单有效的办法是路由守卫 + 动态菜单:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } if (to.meta.roles && !to.meta.roles.includes(userStore.role)) { next('/403'); return; } next(); });路由表里给每个页面标记允许访问的角色:
{ path: '/course-manage', name: 'CourseManage', component: () => import('@/views/admin/CourseManage.vue'), meta: { roles: ['ADMIN'] } }菜单则根据当前用户的角色动态生成。用户信息存在 Pinia 里,登录成功之后拉取一次,菜单组件里写一个计算属性,按角色过滤菜单项。这块做得好,系统就"活"了——不同人登录看到的东西不一样,演示的时候特别加分。
5.2 Axios 封装的两件小事:Token 注入和 401 处理
Axios 拦截器是前端必封装的。一件小事是请求拦截器里把 Token 加到请求头:
service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } return config; });另一件是响应拦截器里处理 401 状态码。Token 过期之后后端会返回 401,这时候不能每个页面各管各的,而是统一在拦截器里清掉本地的 Token,跳转到登录页:
service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );这里有个容易踩的坑:401 跳转之后,如果登录页的路由守卫又允许访问,可能会造成循环跳转。我的处理方式是在跳转前先判断router.currentRoute.value.path !== '/login'再跳,避免死循环。
5.3 表格页的通用写法:搜索表单 + 表格 + 分页三件套
学生管理、教师管理、课程管理这些页面,结构几乎一模一样:上面一个搜索表单,中间一个表格,下面一个分页器。写多了之后你会发现这套写法是固定的:
- 定义查询参数对象
queryParams - 请求分页接口,把数据塞进
tableData和total - 搜索按钮把
currentPage重置为 1,重新请求 - 分页器的
@current-change事件触发重新请求
搜索表单里有个细节:表单里放太多搜索条件会让页面很乱,一屏放不下。我的做法是只放两三个高频条件(姓名、学号),其他条件收进"高级搜索"折叠区,或者干脆不放。管理系统不是功能越多越好,用得顺手才是真的。
5.4 选课页面的交互细节:按钮禁用逻辑
选课页面的难点在"什么情况下选课按钮可点"。
每门课显示的信息包括:课程名、学分、教师、容量、已选人数、上课时间。选课按钮的状态有这么几种:
- 已选:按钮显示"已选",禁用
- 已满:按钮显示"已满",禁用
- 时间冲突:按钮显示"时间冲突",禁用
- 可选:正常显示"选课",可点
这些状态前端怎么知道?我在课程列表接口返回数据里加了一个字段selected(当前学生是否已选)和full(是否已满)。时间冲突数据需要另外判断,我的做法是接口返回该学生已选课程的时间集合,前端在渲染时自行比对。
这个页面对新手来说代码量稍有挑战,但逻辑不复杂。展示层代码不用写太高级,渲染函数里根据course.selected、course.full返回不同 Button 状态,够用就行。
5.5 表单校验:不能只会 required
表单校验是管理系统里最容易被忽略、也最直观的地方。Element Plus 的el-form自带rules规则,至少要把这些场景涵盖:
- 必填项:不能为空
- 学号/手机号:格式校验(长度、数字)
- 邮箱:格式校验
- 年龄/学分:必须是非负数字,有合理范围
举个实际例子,录入成绩的时候,成绩必须限制在 0 到 100 之间,且支持一位小数:
const scoreRules = { score: [ { required: true, message: '请输入成绩', trigger: 'blur' }, { pattern: /^(100|[1-9]?\d(\.\d)?)$/, message: '成绩需为 0-100 的数字', trigger: 'blur' } ] };校验规则写得好不好,直接体现在演示过程中。你给老师演示的时候,输入一个非法数据,页面弹出清晰的中文错误提示,比输入随便什么东西都静默通过,观感一个天上一个地下。
6. 前后端联调踩坑实录:跨域、时区、级联删除
写单测的时候一切完美,一联调就各种姿势翻车,这是前后端分离项目的常态。学生管理系统里最常见的几个坑,我都踩过,直接说结论。
6.1 跨域问题:CORS 配置的几个关键细节
前端跑在http://localhost:5173,后端跑在http://localhost:8080,端口不同,浏览器就会拦截跨域请求。解决办法是后端配置跨域,Spring Boot 里一个配置类搞定:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个细节坑:allowedOrigins("*")和allowCredentials(true)不能同时用,这是浏览器的安全策略限制。解决办法是用allowedOriginPatterns("*")代替allowedOrigins("*"),两个就能同时用了。我就是在这里卡了半天,浏览器控制台报错信息又不够直白,排查起来特别费劲。
另外,加了 JWT 拦截器之后,预检请求 OPTIONS 要放行。因为跨域请求会先发一个 OPTIONS 请求探测服务器允不允许,如果拦截器把这个请求也拦了,真正请求就发过去了出不来。拦截器里要加判断:if ("OPTIONS".equals(request.getMethod())) { return true; }。
6.2 日期显示不对:时区和序列化的问题
前端页面上学生入学时间显示成了"2024-07-31T16:00:00.000+00:00"这种格式,或者显示出来少了 8 小时。这是时区问题,后端返回的是 UTC 时间,前端没转成东八区,或者 JSON 序列化的时候格式没指定。
我的处理方式是双保险:
第一,MySQL 连接 URL 指定时区:
jdbc:mysql://localhost:3306/student_ms?serverTimezone=Asia/Shanghai第二,Spring Boot 里统一配置日期序列化格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai配置完之后,前端直接拿字符串显示,不用再做任何时区转换。这里建议纯日期字段(比如入学年份)直接返回yyyy-MM-dd,日期时间字段返回yyyy-MM-dd HH:mm:ss,前端展示就非常干净。
6.3 删除学生时,选课表怎么办:不能不管
假设一个学生选了好几门课,管理员在系统里删掉这个学生,如果不做任何处理,选课表里就留下了没有主体学生的孤儿记录。查询"某门课的选课名单"时,这些数据就会变成空行或者报错。
处理方案三选一:
- 逻辑删除(推荐):学生表记录打上删除标记,选课表数据原样保留,查询时关联学生表时过滤掉已删除的即可。历史数据完整,以后想恢复也能恢复。
- 级联删除:物理删除学生,同时删除该学生的选课记录。可以用 MySQL 外键 ON DELETE CASCADE,也可以在业务代码里先删选课再删学生。方案简单,但是历史数据没了。
- 禁止删除:如果该学生存在选课记录,则不允许删除,提示"请先处理该学生的选课记录"。这种在真实业务里也有应用场景,比如学籍系统不允许随便删人。
我用的是第一个方案。逻辑删除配合 MyBatis-Plus 的@TableLogic之后,查询、删除、恢复都很优雅,演示的时候还能讲出"为什么不用物理删除"的道理,属于低成本高收益的功能点。
6.4 事务不生效的一个隐蔽场景
选课接口里加了@Transactional,但有时候你会遇到"插入选课记录成功,更新课程人数失败,但数据没回滚"的情况。这个坑我排查过一次,原因是我在代码里手动 catch 了异常没往外抛。
try { courseMapper.increaseSelectedCount(courseId); } catch (Exception e) { // 吞掉异常,没抛出 }Spring 的事务是通过异常触发回滚的,如果异常被你 catch 住吃掉,事务自然就不会回滚。正确做法是 catch 住之后记录日志,然后重新抛出,或者干脆不 catch 直接让异常传出去。
另一个场景是@Transactional加在同类内部方法调用时可能不生效。如果enroll()方法在同一个类里被另一个非事务方法调用,Spring 代理不会拦截内部调用,事务注解就失效了。解决办法是确保事务方法通过外部调用,或者把事务方法放到不同的 Service 类里,或者像我在代码里做的那样,事务加在对外提供的公开方法上,内部私有方法不要拆到别的类再去调用。
7. 系统做完之后:还能往哪些方向演进出彩
大部分课设做到"能跑通"就停了。但同样的工作量,稍微往深走一步,输出价值完全不一样。这个系统做完之后,我梳理过几个值得扩展的方向,有些我已经做了,有些留给后面继续折腾。
7.1 性能优化方向:从接口响应时间和查询效率入手
选课列表页面在数据量小的时候秒开,但随着课程数量增加到几百门、选课记录几千条,接口响应明显变慢。这个阶段可以做两件事:
一是给高频查询加索引。比如enrollment表的student_id字段、course_id字段,course表的teacher_id字段,这些是查询中几乎必带的条件。
CREATE INDEX idx_enrollment_student ON enrollment(student_id); CREATE INDEX idx_enrollment_course ON enrollment(course_id); CREATE INDEX idx_course_teacher ON course(teacher_id);二是引入 Redis 做缓存。课程列表是典型的读多写少场景,可以缓存热门课程的查询结果,选课成功后主动删缓存,让下次查询重新加载。课设阶段引入 Redis 算是一个亮点,但要注意别为了用而用——如果数据量很小,缓存带来的收益甚至不如多查一次数据库来得直观。
7.2 功能扩展方向:绩点计算、Excel 导出、消息通知
让我觉得收益最大的扩展功能是绩点计算和Excel 导出。
绩点计算就是把成绩转换为绩点:90 分以上 4.0,每降一个分数段减 0.1,最后按学分加权平均。这个功能不复杂,但能串联起成绩表、课程表、学生表多张表的数据,做一个完整的业务统计页面。演示的时候输入几个成绩,自动算出绩点排名,效果相当直观。
Excel 导出在管理后台里是高频需求,用 EasyExcel 或者 POI 都行。把学生列表导出成 Excel,代码量不大,但对"实用感"的提升非常明显。我建议做这个功能的时候,把"导出当前筛选条件下的所有数据"作为需求,而不是只导出当前页的 10 条——实际使用中谁会只要这一页的数据?
消息通知功能(选课成功通知、成绩发布通知)属于锦上添花,用 WebSocket 做实时通知太复杂,简单做法是做一个站内信表,登录后按时间倒序列出未读消息。这个功能放到 P2 优先级就好,别让它挤占主流程的时间。
7.3 部署上线:别让你的系统只活在 localhost 里
课设答辩的时候,很多人的系统是现场开 IDEA,现场 run 起来,然后开浏览器 localhost 访问。这样不是不行,但如果你能把系统部署到一台服务器上,用 IP 或者域名访问,观感完全不同。而且部署这件事本身就很有学习价值。
我当时的部署方案是:
- 后端
mvn package打包成 jar,扔到一台 Linux 服务器上,用nohup java -jar xxx.jar跑起来 - 前端
npm run build打包成静态文件,放到 Nginx 的 html 目录 - Nginx 配置反向代理,
/api开头的请求转发到后端的 8080 端口
关键的 Nginx 配置片段:
server { listen 80; server_name your-domain.com; location / { root /opt/student-system/dist; 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; } }try_files $uri $uri/ /index.html这一行很重要,Vue Router 用 history 模式时,刷新非根路径会 404,这行配置就是让所有路由都回落到 index.html,把路由交给前端处理。
部署这事我第一次折腾了一个周末才跑通,最大的收获是理解了"开发环境"和"生产环境"的差异:配置文件要拆成 dev 和 prod 两份,跨域配置在生产环境里其实根本不需要(因为前后端同源了),日志要写到文件里而不是只在控制台输出。这些经验都是在本地开发时永远碰不到的。
最后再分享一个我做这个东西最大的体会:学生管理系统这种"烂大街"的项目,恰恰是最适合练手完整开发流程的选题。它的复杂度刚好能覆盖到一个正经业务系统的大部分环节,又不至于让你陷在某个特别难的技术点里出不来。做完一遍,你会对用户认证、权限模型、数据一致性、前后端交互这些概念有一个具体的体感,这些东西是看多少篇教程都换不来的。
如果你也正在做学生管理系统,我给你的建议就一条:别满足于把功能跑通,多问自己几个"为什么"。为什么选课要用事务?为什么密码要加密?为什么删除要逻辑删?为什么接口要做权限校验?这些问题问下来,你会发现收获比代码本身多得多。