这些年我见过不少毕设和实际落地项目高校学生课程管理系统属于最经典的那一类题目看起来不复杂无非是学生、课程、选课、成绩、教师管理这些词但真正从零开始设计到上线运行要踩的坑远比想象中多。选课冲突怎么处理、成绩归属怎么算、不同角色的权限边界在哪儿、学期切换后数据如何隔离这些问题才是系统的核心骨架SpringBoot 和 Vue 只是实现骨架的工具。基于 SpringBoot Vue 的组合在课程管理这类系统中已经是相当成熟的主流方案后端用 Java 生态的稳定性去扛复杂业务逻辑前端用 Vue 做快速迭代和交互体验。这篇文章不会只讲概念而是把从需求分析、数据库设计、后端接口开发、前端页面搭建到部署上线的完整链路拆开来说并穿插我在实际开发中积累的排错经验。内容主要面向两类人一是准备拿这个题目做毕业设计的同学二是刚接触前后端分离开发、想通过一个完整项目把 SpringBoot 和 Vue 串起来的初级工程师。读完以后你不仅知道怎么写代码还能理解每个关键设计背后的原因。1. 这类系统到底在做什么角色、流程与需求边界1.1 三个核心角色与四条业务主线我先说结论高校学生课程管理系统本质上就是围绕“课程”这个中心词把学校教学管理中的多个参与角色和业务流程数字化。最常见的角色有三种——管理员、教师、学生。很多系统还会拆出教务员或辅导员但最核心的还是这三类。角色对应的业务主线可以拆成四条学生主线登录系统后查看本学期课表、浏览可选课程、提交选课、查看已选课程和成绩单。学生端的核心诉求是“界面清爽 操作结果明确”选课成功就是成功失败要给明确原因。教师主线查看自己负责的课程、获取选课学生名单、录入成绩、修改成绩并留痕。教师端最容易出问题的部分是成绩提交后的确认机制需要避免误操作直接覆盖。管理员主线维护学生和教师的基础信息、维护课程库、安排每学期的开课计划、处理调课和选课异常。管理员是整个系统的“总调度”功能点最多也最容易堆砌。公共基础线登录认证、个人密码修改、学期切换、数据统计。这条线容易被忽略但它决定了系统能否真正投入使用。四条线之间有明显的依赖关系管理员维护的课程库和开课计划是学生选课的数据源学生在某个学期选课成功后才会形成教师所见的选课名单成绩录入完成后又反过来影响学生的成绩单。这个闭环理顺了系统的开发难度就降低了一半。1.2 功能边界划分哪些必须做哪些可以砍做这类系统最容易犯的错误是功能越加越多最后变成“大杂烩”。我在实际开发中总结了一个需求取舍原则凡是需要人工审批的流程先砍凡是只有管理员低频使用的功能先简凡是涉及通知、聊天、社交的功能一律不碰。必须做并且要做扎实的功能我建议控制在几个模块内用户认证模块登录、退出、密码修改、验证码可选。基础信息管理学生信息、教师信息、班级信息、课程库信息含 Excel 导入导出。教学计划管理每个学期生成开课计划绑定任课教师、上课时间、上课地点、选课容量。选课管理学生按学期选课选课前查看课程详情选课结果实时反馈支持退课。成绩管理教师录入成绩成绩发布前可修改发布后学生可见支持成绩区间统计。课表查看学生和教师按学期查看课表课表以周为单位呈现。我在很多项目里看到有人把“教室申请”“教材管理”“教学评价”都塞进课程管理系统。不是说这些功能没用而是它们会让数据模型复杂很多。如果是要在几个月内完成的毕业设计或者是一个小团队内部用的管理系统砍掉这些附属功能把核心链路做顺反而能拿到更好的评价。1.3 系统的核心难点集中在哪三个地方功能清单列完紧接着要回答“难点在哪里”。这个系统真正容易做砸的不是 CRUD而是下面三件事。第一选课冲突的处理。这里的冲突有两层含义一层是同一学生选了两门上课时间重叠的课程另一层是课程容量已满。前者需要在选课接口里做时间段交叉判断后者需要做库存扣减。时间重叠判断必须基于教学计划中精确的周几、第几节课来建模而不是简单比较日期。第二成绩归属和权限划分。成绩表必须能追溯到具体的选课记录避免出现“课程删了成绩还在”或者“同一个学生重新选课后旧成绩覆盖新成绩”的问题。同时教师只能看到并录入自己教的课程的选课记录这个限定在接口层必须做强制校验前端隐藏菜单只是辅助。第三学期切换时的数据隔离。如果系统连续使用多个学期那么选课记录、开课计划、成绩都必须以学期为维度隔离。很多初版设计会忽略学期字段导致新学期数据直接混在一起。我建议所有业务表都带上semester_id外键查询时强制带上学期条件。难点明确后后面的设计就有了方向数据库建模优先考虑冲突检测和权限校验接口设计优先保证事务一致性和操作留痕。2. 技术选型为什么是 SpringBoot Vue 而不是其他方案2.1 前后端分离带来的实际收益曾经的老式 JavaWeb 项目用 JSP 做页面后端既要写业务逻辑又要写 HTML 拼接前端代码和后端代码挤在同一个工程里。平时写写 Demo 没问题但课程管理系统这种涉及多个角色、多套页面的项目再用 JSP 会很难受改了页面样式要重新编译、部署多人协作时前后端冲突不断。换成 SpringBoot Vue 的前后端分离方案后收益非常明显后端只提供 JSON 格式的 REST API专注业务逻辑、权限校验和数据持久化。前端用 Vue 维护页面状态通过 Axios 调用接口接口协议用 JSON 清晰定义。前后端各自独立开发、独立测试后端可以用 Postman 或 Swagger 调试接口前端可以用 Mock 数据开发页面。部署时有两种选择一种是用 Nginx 托管前端静态文件并反向代理后端接口另一种是把前端打包后的dist目录放进 SpringBoot 的static目录合并为一个进程。对于课程管理系统这种规模两种方式都行我推荐前者留给后续扩展空间。此外如果接手项目的人要改需求前后端分离的结构也更容易定位问题页面显示不对直接看 Vue接口数据不对直接看 SpringBoot。2.2 后端SpringBoot 生态与 MyBatis-Plus 的组合后端选择 SpringBoot 基本没有悬念。SpringBoot 简化了 Spring 的配置内置 Tomcat打一个 Jar 包就能跑对部署非常友好。生态里现成的组件几乎覆盖了这类系统的所有需求Spring Security 或拦截器做认证、Spring Data JPA 或 MyBatis-Plus 做持久化、Redis 做缓存、消息通知则需要另接。我个人的偏好是SpringBoot MyBatis-Plus而不是 JPA。原因很实际课程管理系统的查询条件多而杂比如按学期查开课计划、按教师查课程、按学号查成绩经常需要自定义 SQL 和条件构造器。MyBatis-Plus 对单表 CRUD 的封装足够省事复杂查询又能写 XML SQL控制力更强。反观 JPA学习曲线更陡而且国内很多教材和资料都基于 MyBatis-Plus遇到问题搜答案容易。MyBatis-Plus 的几个常用特性在项目中能直接派上用场QueryWrapper条件构造器动态拼接查询条件避免写一堆if SQL。分页插件学生列表、课程列表、成绩列表都需要分页配置一个MybatisPlusInterceptor即可。逻辑删除学生退课记录不要物理删除用逻辑删除保留痕迹。字段自动填充create_time、update_time字段自动填充省去每条记录手动赋值的时间。Java 版本我建议用 JDK 8 或 JDK 17对应 SpringBoot 2.x 或 3.x。如果是为了稳定和省心SpringBoot 2.7.x JDK 8 的组合更成熟资料最多第三方依赖兼容性也最好。如果追求新特性SpringBoot 3.x JDK 17 也可以但要注意一些老版本的工具类可能冲突。2.3 前端Vue 3 Vite Element Plus 的合理版本组合前端的选型我推荐 Vue 3 Vite Element Plus。Vue 3 的 Composition API 在组件逻辑复用上比 Vue 2 的 Options API 方便很多Vite 的冷启动速度也比 Webpack 快得多Element Plus 则提供了表格、表单、对话框、分页等现成组件开发管理后台的效率非常高。需要注意的坑是版本匹配。Element Plus 是配合 Vue 3 使用的如果你拿到一个 Vue 2 项目要对应 Element UI两个组件库不通用。建议新建项目时直接从 Vite 官方脚手架创建 Vue 3 项目然后安装 Element Plus避免版本错乱。前端项目我习惯按模块组织目录src/ api/ // 每个模块的接口请求封装 assets/ // 静态资源 components/ // 通用组件 router/ // 路由配置 store/ // 全局状态选课状态、用户信息 views/ // 页面视图按角色分目录 utils/request.js // Axios 实例封装request.js的封装很关键。我在里面统一做了三件事请求拦截器带上 token、响应拦截器统一处理业务错误码、401 状态码自动跳转登录页。这三个逻辑如果不放拦截器里每个接口都要重复写代码会非常啰嗦。2.4 开发环境的版本选择经验版本选择这块我的建议是不要盲目追求最新。很多初学者一上来就用最新版 IDE、最新版 JDK、最新版 SpringBoot结果碰到依赖冲突或者插件不兼容查问题查半天。开发这类稳扎稳打的课程管理系统我的环境配置如下JDK 8如果新写项目用 JDK 17 也可以Maven 3.6 或 Maven 3.8SpringBoot 2.7.xNode.js 16 或 18对应 Vite 4 和 Vue 3.3IDEIntelliJ IDEA VS Code 组合IDEA 写后端VS Code 写前端这个组合我用过很多次基本不会遇到版本层面的硬伤。数据库中规中矩选 MySQL 5.7 或 8.0不需要上更复杂的数据库。如果要上缓存Redis 5 以上即可。如果你只是做毕业设计那么不引入 Redis 也能完成因为选课并发的规模在毕设演示场景下不会太高把事务和锁做好就够了。3. 数据库设计把课程业务拆成一张张表3.1 核心表结构的拆解数据库设计是整个项目的地基。我习惯先画一张核心表清单再逐表展开字段。基于前面确认的业务闭环我设计了下面这些表表名作用关键字段备注sys_user系统用户统一表id, username, password, real_name, role, status学生、教师、管理员共用一张表用 role 区分student_info学生扩展信息id, user_id, student_no, class_id, grade与 sys_user 一对一teacher_info教师扩展信息id, user_id, teacher_no, title与 sys_user 一对一class_info班级信息id, class_name, major_name, grade学生归属班级course课程库id, course_code, course_name, credit, hours课程基础档案semester学期id, semester_name, start_week, total_weeks区分不同开课周期course_plan开课计划id, semester_id, course_id, teacher_id, capacity, selected_count, course_time, location某学期某课程由谁教、何时上课student_course选课记录id, student_id, course_plan_id, semester_id, status, score选课退课和成绩都落在这张表time_slot时间段字典id, name, day_of_week, start_slot, end_slot避免硬编码上课时间这个设计的核心思想是学生和教师不直接跟课程表挂钩而是统一挂到开课计划上。开课计划是每个学期生成的“课程实例”学生选课选的是开课计划教师教课的也是开课计划。这样同一个课程库里的课程在不同学期由不同老师授课数据不会串。3.2 用 semested 字段锁定学期维度课程管理系统运行一学期之后一定会产生大量历史数据。最坏的情况是新学期开始管理员创建新的开课计划后学生登录系统看到的是所有学期的课程选课记录也混在一起。解决办法就是在选课记录和开课计划中强制带上semester_id。我写的所有涉及教学业务的 SQL基本都要求带上学期条件。前端页面的筛选栏也默认加一个学期下拉框方便学生和教师切换查看不同学期的课表与成绩。另外要注意学期表应该维护一个“当前学期”的标记比如is_current字段。登录后默认查询的就是这个当前学期避免学生选课时选择到历史学期的课程。管理员切换学期时只需要改这个标记系统整体数据视角就会跟着切。3.3 选课冲突与排课时间的建模选课冲突是数据建模里最需要深思的环节。如果用字符串“周三 3-4 节”来记录上课时间那么检测冲突就得解析字符串极其痛苦。我的做法是把“上课时间”拆成三个数字day_of_week周几、start_slot开始节次、end_slot结束节次。在开课计划表里存这三个字段再存一个location表示上课地点。判断两个课程是否时间冲突的逻辑就变成了同一学生的两门课如果day_of_week相同且课程 A 的节次区间与课程 B 的节次区间有交集则冲突。对应的判断代码伪逻辑如下// interval overlap check, assuming slots are integer if (a.getDayOfWeek().equals(b.getDayOfWeek())) { boolean overlap a.getStartSlot() b.getEndSlot() b.getStartSlot() a.getEndSlot(); if (overlap) { throw new BusinessException(课程时间冲突 a.getCourseName() 与 b.getCourseName()); } }这个判断模型还可以扩展出教室冲突如果同一开课计划的时间段和地点与另一计划完全相同那么也要给出提示。把时间建模成数字区间后这类判断都变得清晰简单。实际做课表页面时前端拿到day_of_week, start_slot, end_slot后可以直接绘制到表格中我给课表组件传递的是一个二维数组横轴是周一到周日纵轴是第 1 节到第 10 节每个格子填充对应课程名称。这样实现起来非常直观。3.4 数据字段设计的几个坑先说说容量与已选人数的关系。开课计划表里我设计了capacity和selected_count两个字段。capacity是课程容量selected_count是当前已选人数。很多初版设计会忘记selected_count字段选课成功后临时去查student_course表的条数来算这样做不仅性能差而且在高并发情况下很难做容量控制。再来说逻辑删除与唯一约束的冲突。学生退课后如果对student_course表做物理删除那么再次选课时重新插入记录没有问题。但如果做逻辑删除把is_deleted置为 1并且给表加了student_id course_plan_id的唯一索引就会出现问题退课记录还在新选课插不进去。解决方案是唯一索引改成student_id course_plan_id is_deleted或者退课时顺便把逻辑删除字段重置。实际项目中我遇到过这个坑排查了很久。最后是成绩字段的默认值。选课记录里的score字段在未录入成绩前应为空。不能默认设为 0否则查询平均分时会把未考试的课程算成 0 分统计结果就错了。成绩发布这个动作一定要显式地把score从 null 更新为具体数值。4. 后端实现SpringBoot 核心模块落地4.1 项目分层与代码结构SpringBoot 后端项目的分层我遵循常规方案Controller、Service、Mapper 三层外加 DTO、VO、Common 工具包。对于课程管理系统这种规模不需要引入复杂的 DDD 设计清晰的分层足够用com.example.course ├── common // 统一返回、异常处理、常量 ├── config // 配置类拦截器、CORS、MyBatis-Plus 插件 ├── controller // 接收请求参数校验返回 VO ├── service/impl // 业务逻辑 ├── mapper // MyBatis-Plus 的 Mapper 接口 ├── entity // 数据库实体 ├── dto // 接收参数对象 ├── vo // 返回视图对象 └── utils // JWT、日期工具统一返回类很有必要。我习惯定义一个ResultT包含code、message、data三个字段成功是 200业务异常用 500 或自定义错误码。规范了返回结构后前端依托响应拦截器处理各种情况就很统一。4.2 JWT 认证与拦截器实现登录认证我选用 JWT而不是 Spring Security 的重量级配置。原因是课程管理系统的权限模型相对简单角色数量少用拦截器 JWT 就能实现代码也更容易读懂。Spring Security 功能强大但配置项多对刚接触的新手不太友好。登录流程是这样用户提交用户名和密码后端校验sys_user表中的记录密码用 BCrypt 加密比对比对成功后生成 JWT token返回给前端。token 里我会放三个信息userId、username、role。JWT 生成工具的核心逻辑大致如下public String createToken(Long userId, String username, String role) { long now System.currentTimeMillis(); return Jwts.builder() .claim(userId, userId) .claim(username, username) .claim(role, role) .setIssuedAt(new Date(now)) .setExpiration(new Date(now 1000L * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }写一个拦截器在预处理阶段从请求头Authorization中提取 token解析并校验签名和过期时间。解析失败直接返回 401让前端跳回登录页。需要注意的是拦截器要放行登录接口、验证码接口其他接口一律拦截。4.3 高校选课接口的事务与并发处理选课接口是后端最容易出问题的部分。学生点击选课后要执行的操作包括检查课程计划是否存在、检查是否已选过该课程、检查时间是否冲突、检查容量是否还有余量、插入选课记录、更新selected_count加 1。这六步必须在一个事务里完成任何一步失败都应该回滚。用Transactional注解标记选课方法还不够并发场景下会有超卖问题。想象两个学生同时点击选课两个请求同时读到selected_countcapacity都认为还有余量然后一起执行插入最终超出容量。解决方式有两种一种是用数据库行锁查询数据时加SELECT ... FOR UPDATE另一种是用乐观锁在course_plan表增加version字段更新时带上版本号判断。对于毕设和中小型系统我推荐乐观锁实现简单可靠。更新selected_count的 SQL 写法是UPDATE course_plan SET selected_count selected_count 1, version version 1 WHERE id #{planId} AND version #{oldVersion} AND selected_count capacity如果受影响行数为 0说明容量满了或版本已变化直接抛出“选课人数已满”的异常。注意这个更新语句要在插入选课记录之前执行通过先占用名额来避免超卖。同时选课还依赖数据库的唯一索引来防止同一学生重复选同一门课。我在student_course表上加了student_id course_plan_id的唯一索引即使代码逻辑漏了重复校验数据库也会兜底报错。4.4 成绩录入与权限校验成绩录入接口的权限控制要非常严格。教师在页面上打开授课课程列表选择一门课看到的学生名单必须限定为“该教师负责的开课计划下的选课记录”。最容易出越权问题的地方就在这如果查询学生名单的接口只接收coursePlanId没有校验当前登录教师是否真的负责这门课那么教师可以猜测其他课程 ID越权查看和录入成绩。我在 Service 层强制增加校验根据coursePlanId查出开课计划比对计划里的teacher_id与当前登录用户的教师信息不一致直接抛业务异常。这种校验不要放在前端做因为前端隐藏按钮只能防君子不能防手动请求。成绩录入还有一层业务逻辑已发布与未发布。我设置了status字段来表示选课记录成绩状态未录入时为ENROLLED教师录入成绩后为SCORED提交发布后才允许学生查看。录入但不发布成绩对学生不可见。这样有效防止教师手滑批量提交而无法撤回。成绩发布后如果教师想修改我通常设计成可以申请修改但修改操作要在成绩修改记录表里留下日志。日志内容至少包含旧成绩、新成绩、操作时间、操作人。比如old_score85, new_score90, operatorteacher_001, timestamp2025-01-10 14:32:00这个需求在初版设计时容易被忽略等真正用了发现成绩改错没有追溯问责困难所以建议第一时间就把修改日志表建好。5. 前端实现Vue 侧的关键细节5.1 项目初始化与请求封装前端我使用 Vite 创建 Vue 3 项目。执行npm create vitelatest并选择 Vue 模板后安装 Element Plus、Axios、Vue Router、Pinia。安装完成后我第一件事就是配置请求封装。以下是utils/request.js的核心思路import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, // 由 Vite 代理或 Nginx 反代到后端 timeout: 10000 }) // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) ElMessage.error(登录已过期请重新登录) } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } )这个封装的意义在于业务代码里不需要关心 token 怎么带也不需要在每个接口后处理错误弹窗。统一处理后页面上的代码可以很干净const data await getCoursePlanList({ semesterId }) // 直接用 data省去一层 data.data5.2 路由守卫与动态权限前端路由可以用静态配置也可以用动态权限。对于课程管理系统我建议做一个折中先把所有页面都定义好但通过路由守卫和菜单渲染让不同角色只能进入自己对应的页面。路由守卫的核心逻辑router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } if (to.path /login token) { next(/) return } const role localStorage.getItem(role) if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) // 无权限页面 return } next() })在路由定义中通过meta.roles描述哪些角色可以访问。比如{ path: /teacher/course-list, component: TeacherCourseList, meta: { roles: [TEACHER] } }实际使用中路由守卫加上后端接口的权限校验两层保障前端只控制菜单显示和页面跳转真正的数据安全靠后端。5.3 核心页面选课与成绩录入的表单交互选课页面是学生用得最多的页面。界面设计上我分成左右两个区域左侧是可选的课程列表右侧是已选课程列表。左侧课程列表展示课程名称、学分、开课教师、上课时间、地点、容量和已选人数。选课按钮点击后如果成功列表中的已选人数立刻加 1按钮变成“已选”。这里不要刷新整个页面而是局部更新数据体验会明显好于整页刷新。课程数据量大的时候前端可以在展示时间时把day_of_week和start_slot/end_slot转成中文比如“周三 第3-4节”。转换逻辑写成工具函数避免在多个组件里重复处理。成绩录入页面是教师端最核心的页面。我使用 Element Plus 的el-table展示学生名单每一行的“成绩”列用可编辑的el-input-number组件。教师可以批量修改后统一提交也可以在页面底部显示“录入进度”几个学生未录一目了然。提交时通过后端接口把整张表的成绩数据一次性传过去在事务中逐条更新。这里要特别提醒成绩输入框需要做合法值校验。成绩一般是 0-100 的整数但有些学校采用五分制或者及格/不及格制。我在 DTO 层用注解校验DecimalMin和DecimalMax同时前端也做限制双端校验防止脏数据入库。6. 权限方案设计不同角色看到的内容如何隔离6.1 RBAC 权限模型的实际落地课程管理系统的权限模型可以用最简单的 RBAC 实现用户表、角色字段、菜单表、角色菜单关联表和用户角色关联表。但考虑到角色数量固定且少我把角色直接存在sys_user.role字段菜单则在前端写死路由配置里没有引入动态菜单表。这种方式的好处是代码简单坏处是灵活性差。如果未来要新增一个“助教”角色需要改代码。对于毕业设计和中小型系统简单优先可以先这样做。如果你想把 RBAC 做得更规范可以在系统里建sys_menu和sys_role_menu表用后端接口返回该角色可访问的菜单列表前端动态渲染侧边栏。在角色权限细分上我建议至少区分以下几种能力的组合能力管理员教师学生维护学生信息是否否维护课程库是否否创建开课计划是否否查看选课名单否是否录入成绩否是否在线选课否否是查看成绩单是部分是数据统计是有本课统计否这张权限矩阵是后端接口校验的直接依据。每个接口在 Service 层都按这个矩阵做校验只有管理员能调用的接口就先判断role ! ADMIN然后抛异常。6.2 后端越权校验与数据级权限角色级权限只是第一步数据级权限更隐蔽。课程管理系统中常见的数据级越权有这些场景教师 A 登录后试图通过修改请求参数查看教师 B 的课程信息。学生 X 选课后试图查看和修改学生 Y 的选课记录。管理员试图查看所有学生的成绩单管理员有权限但查询范围要控制。针对这些场景我的通用原则是接口接收的参数中凡是涉及数据归属的 ID都要从当前登录用户信息中推导而不是直接信任前端传来的值。比如学生只能查自己的选课记录那么查询接口就不应该接收studentId参数而是后端自动从 token 中解析出当前用户的 ID。如果某接口需要接收业务对象的 ID则必须校验该对象的归属。例如// 学生查看自己的选课记录 Long studentId getCurrentUserId(); // 从 token 解析 // 不使用前端传入的 studentId // 教师查看课程学生名单 CoursePlan plan coursePlanMapper.selectById(coursePlanId); if (!plan.getTeacherId().equals(currentTeacherId)) { throw new BusinessException(无权访问该课程); }这种校验在编码时需要多写几行但确实能避免大量安全问题。很多毕设项目在答辩时被老师随便改参数就发现越权非常影响评分。6.3 前端菜单动态渲染前端菜单根据角色动态渲染我通常用一个菜单配置数组每项指定roles字段const menuConfig [ { title: 首页, path: /dashboard, icon: HomeFilled }, { title: 学生管理, path: /admin/students, roles: [ADMIN] }, { title: 教师管理, path: /admin/teachers, roles: [ADMIN] }, { title: 课程库管理, path: /admin/courses, roles: [ADMIN] }, { title: 开课计划, path: /admin/plans, roles: [ADMIN] }, { title: 在线选课, path: /student/select-course, roles: [STUDENT] }, { title: 我的课表, path: /student/schedule, roles: [STUDENT] }, { title: 成绩查询, path: /student/score, roles: [STUDENT] }, { title: 我的授课, path: /teacher/plans, roles: [TEACHER] }, { title: 成绩录入, path: /teacher/score-entry, roles: [TEACHER] } ]渲染菜单时过滤掉与当前角色不匹配的条目。登录后把token和role存到 localStorage页面刷新时从 localStorage 恢复菜单。这套逻辑不复杂但能避免学生登录后看到管理员菜单的尴尬也让整个界面更专业。7. 部署与常见问题排查7.1 前端打包与后端部署系统开发完成后部署部分最容易让新手卡住。先说前端构建在 Vue 项目根目录执行npm run build会在dist目录生成静态文件。这里有个关键配置是前端请求的baseURL。开发环境我用/api并通过 Vite 的proxy配置转发到http://localhost:8080生产环境我用 Nginx 将/api反代到后端服务这样前端和后端可以部署在同一个域下避免跨域问题。一个简化版的 Nginx 配置片段server { listen 80; server_name your-domain.com; root /opt/frontend/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }注意try_files配置这是 Vue Router 使用 history 模式时必须的否则刷新页面会 404。如果不想用 Nginx也可以把前端dist目录拷贝到 SpringBoot 的src/main/resources/static下重新打包 Jar这样访问后端地址直接就是前端页面。但这样做有个缺点以后每次前端改动都要重新打后端的包不够灵活。后端部署相对简单。执行mvn clean package打包成 Jar再用java -jar course-system.jar启动即可。生产环境推荐用systemd管理服务配置自动重启和日志输出。7.2 常见报错与解决记录我把实际过程中遇到的高频报错整理成一份速查表方便照着排查报错/问题原因解决方案前端请求 404baseURL 或代理路径不一致确认前端访问路径与后端RequestMapping前缀一致Vite/Nginx 代理正确Swagger 启动时空指针SpringBoot 版本过新与 springfox 冲突用 springdoc-openapi或降低 SpringBoot 版本选课超卖没有加乐观锁或行锁在更新selected_count时加version乐观锁CORS 跨域报错前后端分离部署时未统一网关Nginx 同域代理或在后端配置 CORS 过滤器中文乱码数据库连接未指定 UTF-8在 JDBC URL 加useUnicodetruecharacterEncodingutf8时间显示相差 8 小时MySQL 时区配置问题连接串加serverTimezoneAsia/ShanghaiJackson 配置统一时区上传 Excel 导入失败字段名不匹配或日期格式问题先导出模板严格按模板格式导入导入时做异常行提示前端页面刷新 404Vue Router history 模式缺少 Nginx 重写Nginx 配置try_files ... /index.html教师能访问学生接口后端接口缺少角色校验在 Service 层增加角色校验不能只靠前端菜单隐藏退课后无法重新选课逻辑删除与唯一索引冲突调整唯一索引结构或在退课时彻底释放记录这 10 个问题是我在不同项目里反复遇到的前三个尤其常见。如果你在开发时遇到同样的报错可以按表里的方案直接操作。7.3 数据安全与备份措施系统投入使用后数据安全要提前做规划。我在数据库层面设置了每日凌晨自动备份的定时任务用mysqldump备份关键表。简单的备份命令mysqldump -uroot -p your_database backup_$(date %Y%m%d).sql保留最近 7 天的备份文件即可。此外学生成绩数据不允许物理级随意修改这个在前面的成绩修改日志设计里已经体现数据库层面再加强一下业务表外键约束不轻易删除尤其是student_course与course_plan、course的关联保证成绩永远可以追溯。另外生产环境不要把数据库密码写在配置文件里上传到公开仓库。可以用环境变量注入或使用配置中心管理。虽然课程管理系统的敏感度不高但这是工程习惯值得从早期项目就养成。8. 几个容易被忽略但很重要的扩展点8.1 Excel 导入导出的实现逻辑教务场景里 Excel 导入导出是高频刚需管理员批量导入学生名单教师导出成绩表。后端我推荐用 EasyExcel 而不是 Apache POI 原生 API。EasyExcel 对内存占用更小API 更简洁配合注解可以直接映射实体类字段。例如学生导入的实体public class StudentImportRow { ExcelProperty(学号) private String studentNo; ExcelProperty(姓名) private String name; ExcelProperty(班级) private String className; }导入时先读 Excel 成ListStudentImportRow然后逐行校验并批量插入。校验失败的行要记录具体行号和原因返回给前端下载错误报告。千万不能因为一行错误就让整个导入失败否则批量导入 500 个学生遇到 3 个错误管理员要疯。8.2 课表组件的数据结构设计课表展示是课程管理系统里比较出效果的功能。我的实现思路是用一个二维数组schedule[days][slots]days 范围 0-6 或 1-7slots 范围 1-10。查询到某学生的选课后循环把课程填充到对应的格子。前端用 Element Plus 的el-table或者自定义表格渲染。需要注意一点跨节次的课程要rowspan合并单元格。比如 3-4 节的课程需要跨两行。这块代码逻辑不复杂但容易处理出错。我建议写个工具函数export function generateScheduleMatrix(courses) { const matrix Array.from({ length: 7 }, () Array(11).fill(null)) courses.forEach(course { for (let slot course.startSlot; slot course.endSlot; slot) { matrix[course.dayOfWeek - 1][slot] course } }) return matrix }渲染时再处理合并逻辑效果会比较顺。8.3 统计报表的简易实现课程管理系统通常还要一些统计功能各课程选课人数统计、成绩分布统计、学生学分完成情况。如果项目是为了毕设我用 MyBatis-Plus 的selectMaps加聚合 SQL 就能实现不需要引入额外报表组件。比如统计成绩分布SELECT CASE WHEN score 90 THEN 优秀 WHEN score 80 THEN 良好 WHEN score 70 THEN 中等 WHEN score 60 THEN 及格 ELSE 不及格 END AS grade_level, COUNT(*) AS cnt FROM student_course WHERE semester_id #{semesterId} GROUP BY grade_level前端用 ECharts 的饼图或柱状图展示简单又直观。ECharts 的引入只影响一个页面不会增加太多复杂度。9. 我的一些个人经验和建议这类系统做多了以后最大的体会是技术难点其实有限业务边界和细节才是决定项目及格线的东西。很多学生在答辩时演示的是“系统能登录、能增删改查”而评委老师真正想看的是“选课冲突会不会报错、成绩录入有没有权限校验、数据换学期后会不会乱”。所以不要只顾着把页面做得花哨把业务逻辑的严密性提上去分数自然上去了。再说一个建议项目早期就要把接口文档定下来。我用的方案是 SpringDoc 自动生成 Swagger 文档后端启动后访问/swagger-ui.html就能看到所有接口。前端开发时对着文档调试不用反复问后端“这个接口返回什么”。如果你在团队里做这个习惯能省下大量沟通时间。另外代码提交一定要用 Git。哪怕你是一个人开发也要养成每次改完一个功能就提交一次的习惯。学生管理系统这种项目经常要改需求没有版本管理就等着“一键回到解放前”。我见过好几个项目改代码改到后来不知道改了什么没发还原最后答辩时直接崩溃。最后给自己留充足的测试时间。不要码完代码就开始写论文至少留出一周来系统测试学生选课、退课、成绩录入、发布、修改、学期切换、权限访问把这些路径完整走几遍。你可以写一份简单的测试用例表照着表逐项验证记录测试结果。这份测试记录不仅对你自己排查有帮助放进毕业设计文档里也是加分项。做一个能用的课程管理系统不容易做一个经得住实际使用的更不容易。希望这篇文章能把你的开发过程理顺省掉那些浪费时间的坑。