简介:这是一套基于SpringBoot 2.2.6.RELEASE与Vue.js构建的前后端分离教务管理系统完整源码,采用RESTful API规范进行数据交互,主要面向需要系统学习SpringBoot、Vue.js、MySQL 8.0及分布式开发的高校学生和初中级开发者。压缩包共包含182个文件,以85个Java后端源码、37个Vue前端组件、21个JavaScript脚本、17个XML配置为主,配合SQL数据库脚本、properties配置、JSP页面等构成完整工程;Java文件涵盖控制器、服务层、实体与工具类,Vue文件实现页面与组件化交互,SQL脚本负责数据库初始化,压缩包整体仅2.84MB,目录结构清晰。目前已有1884人学习下载,适合作为毕业设计或企业级一体化开发的入门模板。项目不仅覆盖学生信息管理、课程安排、成绩处理等典型教务模块,还内置Excel导入导出工具类、验证码工具类及管理员安排控制器等功能代码,附带的数据库脚本可一键初始化MySQL 8.0的表结构与示例数据,并将JPA持久层操作封装为可直接复用的模式,帮助学习者理清前后端分离开发中的接口设计、数据处理和模块化开发思路,是快速上手全栈项目的实用参考。
1. 教务系统重构里最容易被跳过的那份数据库脚本
接手这类SpringBoot+Vue前后端分离的教务管理项目时,大多数人会先看Controller和页面组件,等启动报错才发现,真正的坑埋在后端服务构建和前端打包两套体系里。MySQL 8.0要做初始化,Vue实例要发请求,后端要在AdminArrangeController里编排排课接口,这一套链路如果表字段对不上,后面全是无效劳动。项目自带的数据库脚本文件,把建库、建表、初始数据一次配齐,配合Spring Data JPA,连手写SQL的频次都能压下去;而对做课程设计、准备面试项目或者刚接手中小型系统的人来说,先把这条链路理清,比急着把页面跑起来更有实际价值。
2. MySQL 8.0数据库脚本与JPA实体映射
2.1 数据库脚本初始化与核心表结构
教务系统里最容易失控的不是控制器,而是表与表之间的关系。学生、教师、课程、选课、排课、教室时间片,每一张表都不是孤岛。项目自带的数据库脚本文件把这一整套关系用建表语句固定下来:哪些字段允许为空、哪个字段组合唯一、外键落在哪张表,都不靠运行时去推断。手动把脚本跑一遍,比让Hibernate自动生成表结构更可控,至少本地环境和部署环境的结构完全一致。
脚本执行前先确认字符集是utf8mb4。发现脚本里还是utf8时,遇到学生姓名中的生僻字,写入大概率会报Incorrect string value。MySQL 8.0默认字符集已经是utf8mb4,但很多旧脚本把CHARSET=utf8写死了,跑完建表语句后最好用SHOW CREATE TABLE复查一遍。下面这张排课表是这类系统里最常见的设计:
CREATE TABLE course_arrange ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_id BIGINT NOT NULL, class_id BIGINT NOT NULL, teacher_id BIGINT, week_day TINYINT COMMENT '1-7,对应周一到周日', period TINYINT COMMENT '1-8,对应第几大节', classroom VARCHAR(30) NOT NULL, UNIQUE KEY uk_room_time (classroom, week_day, period), KEY idx_course_id (course_id), CONSTRAINT fk_arrange_course FOREIGN KEY (course_id) REFERENCES course (id) ) ENGINE=InnoDB COMMENT='排课表';UNIQUE KEY (classroom, week_day, period)把“教室+星期+节次”锁死,这是防止同一间教室同一时段被两门课占用的数据库兜底方案。业务层依然要自己做冲突检测,因为这条唯一索引只拦同教室时间片不重叠;如果某个老师同一时间被排进两间不同教室,索引管不住,得靠Service层校验。
2.2 application.yml连接配置与MySQL 8.0时区问题
SpringBoot 2.2.6.RELEASE连接MySQL 8.0,最容易踩的坑在时区参数。8.0的JDBC驱动和服务器对本地时区判断不一致,连接串不带serverTimezone,启动时经常报The server time zone value '�й���' is unrecognized,控制台里那串乱码就是系统时区被错误读取的典型特征。
spring: datasource: url: jdbc:mysql://localhost:3306/edu_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialectdriver-class-name用com.mysql.cj.jdbc.Driver,老驱动com.mysql.jdbc.Driver在8.0下虽然能用,但会持续输出deprecated警告。allowPublicKeyRetrieval=true与MySQL 8.0默认的caching_sha2_password认证方式有关,本地如果用mysql_native_password,加不加这行都不影响连接。ddl-auto开发阶段用update方便,实体字段一改表结构跟着变;生产环境建议切换成validate,启动时校验实体与表是否匹配即可,避免自动改表。
2.3 JPA实体映射与Repository分页
表结构定好之后开始写实体。Student对应student表,字段用驼峰命名,列名保持下划线,通过@Column(name = "student_no")显式映射,对象和SQL各用各的命名习惯,互不干扰。排课实体用@ManyToOne关联课程,避免在业务代码里手动拼接外键:
@Entity @Table(name = "course_arrange") public class CourseArrange { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "course_id") private Course course; @Column(name = "week_day") private Integer weekDay; @Column(name = "period") private Integer period; @Column(name = "classroom", length = 30) private String classroom; }Repository层继承JpaRepository后,分页、排序、按字段查询都是现成的。findByWeekDayAndPeriod这种方法名,Spring Data JPA会按约定自动生成查询逻辑,不用手写JPQL:
public interface CourseArrangeRepository extends JpaRepository<CourseArrange, Long> { List<CourseArrange> findByWeekDayAndPeriod(Integer weekDay, Integer period); Page<CourseArrange> findByClassroomContaining(String keyword, Pageable pageable); }表与实体的对应关系可以简单整理成一张表,方便后面开发时对字段:
| 数据库表 | 实体类 | 关键字段 |
|---|---|---|
| student | Student | student_no, class_id, grade |
| teacher | Teacher | teacher_no, name |
| course | Course | course_no, course_name, credit |
| course_arrange | CourseArrange | course_id, classroom, week_day, period |
分页查询返回的Page对象直接序列化成JSON,会带出分页元数据,前端未必需要。比较稳妥的做法是在Controller里手动包装成records + total结构再返回,接口语义更干净。
3. SpringBoot后端:验证码、Excel导入与排课接口
3.1 AdminArrangeController的请求链路
AdminArrangeController是排课功能的入口,一个类集中了查询、保存、删除三类操作。接口设计遵循REST规范,GET /list做分页查询,POST /save新增或修改,DELETE /{id}删除。Controller只做参数接收和结果包装,业务校验放到Service层,后续扩展时Controller才不用频繁改动:
@RestController @RequestMapping("/api/admin/arrange") public class AdminArrangeController { private final CourseArrangeRepository arrangeRepository; public AdminArrangeController(CourseArrangeRepository arrangeRepository) { this.arrangeRepository = arrangeRepository; } @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { Page<CourseArrange> data = arrangeRepository.findAll(PageRequest.of(page - 1, size)); Map<String, Object> map = new HashMap<>(); map.put("records", data.getContent()); map.put("total", data.getTotalElements()); return Result.ok(map); } @PostMapping("/save") public Result save(@RequestBody CourseArrange arrange) { arrangeRepository.save(arrange); return Result.ok(); } @DeleteMapping("/{id}") public Result remove(@PathVariable Long id) { arrangeRepository.deleteById(id); return Result.ok(); } }PageRequest.of(page - 1, size)里page必须减一,前端习惯从1开始,而Spring Data JPA的分页从0计数。这个细节在联调阶段最容易暴露,前端传第2页,后端实际取的是第1页偏移的数据。Result是统一返回体,多数项目已经标准化为code + msg + data三件套,业务异常不再依赖HTTP状态码表达,4xx、5xx之外的情况都走200返回。
3.2 VerifyCodeUtils验证码生成与登录校验
登录入口要配验证码,VerifyCodeUtils就是干这件事的。它无状态、不依赖Spring容器,纯static方法调用,Controller拿到验证码字符串后存Session或Redis,图片以Base64字符串返回给前端。图片本身不需要服务端保存,前端拿到base64直接渲染成图片即可:
public class VerifyCodeUtils { private static final SecureRandom RANDOM = new SecureRandom(); public static String generateCode(int length) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { sb.append(RANDOM.nextInt(10)); // 纯数字验证码,规避大小写混淆 } return sb.toString(); } public static BufferedImage createImage(String code) { int width = 120; int height = 40; BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); g.setColor(new Color(245, 247, 250)); g.fillRect(0, 0, width, height); g.setFont(new Font("Arial", Font.BOLD, 26)); for (int i = 0; i < code.length(); i++) { g.setColor(new Color(30 + RANDOM.nextInt(180), 30 + RANDOM.nextInt(180), 30 + RANDOM.nextInt(180))); g.drawString(String.valueOf(code.charAt(i)), 15 + i * 24, 28 + RANDOM.nextInt(8)); } g.dispose(); return image; } }验证码存Session还是Redis,取决于部署形态。单机部署存HttpSession最简单,但前后端分离后跨域请求要带Session,必须配合allowCredentials(true)和精确的allowedOrigins。一旦后端拆成多实例,Session不再共享,验证码变成A机发、B机验不上,此时要换Redis,key用会话id或带过期时间的随机串,TTL设2分钟足够。
3.3 ExcelUtils实现课程批量导入
系统上线初期,课程清单、学生名单大多存在Excel里,ExcelUtils这类工具的作用就是解析xlsx:读取第0行表头确定列顺序,再逐行把单元格值映射到实体。POI对数字单元格的处理要特别当心,cell.getStringCellValue()拿不到数字内容,按CellType分支处理更安全:
public class ExcelUtils { public static List<CourseImportVO> readCourseExcel(InputStream in) throws Exception { List<CourseImportVO> list = new ArrayList<>(); try (Workbook workbook = WorkbookFactory.create(in)) { Sheet sheet = workbook.getSheetAt(0); for (int i = 1; i <= sheet.getLastRowNum(); i++) { Row row = sheet.getRow(i); if (row == null) continue; CourseImportVO vo = new CourseImportVO(); vo.setCourseNo(getCellValue(row.getCell(0))); vo.setCourseName(getCellValue(row.getCell(1))); vo.setCredit(Double.parseDouble(getCellValue(row.getCell(2)))); vo.setTeacherName(getCellValue(row.getCell(3))); list.add(vo); } } return list; } private static String getCellValue(Cell cell) { if (cell == null) return ""; if (cell.getCellType() == CellType.NUMERIC) { return BigDecimal.valueOf(cell.getNumericCellValue()).toPlainString(); } return cell.getStringCellValue(); } }getNumericCellValue()返回的是double,学号、工号这类长数字直接toString会变成科学计数法,先转BigDecimal再用toPlainString(),才能保留下数字原本的长度,否则0123456789012这类编号进到数据库就变了。
接口方法整理如下,跟前端联调时可以直接对照:
| 方法 | 路径 | 说明 | 请求参数 |
|---|---|---|---|
| GET | /api/admin/arrange/list | 分页查询排课 | page, size |
| POST | /api/admin/arrange/save | 新增/更新排课记录 | CourseArrange JSON |
| DELETE | /api/admin/arrange/{id} | 删除排课 | 路径id |
| POST | /api/auth/login | 登录并返回token | username, password, code |
| POST | /api/common/import/course | 上传Excel导入课程 | multipart文件 |
3.4 统一返回结构与全局异常
Result类要设计得足够薄,只放调用方关心的字段,不必塞多余元数据。全局异常统一交给@RestControllerAdvice处理,Controller不再散落try-catch。校验失败、数据不存在都抛自定义异常,统一由全局逻辑转为Result返回,前端拿到的数据结构始终一致,请求成功或失败都是同一种JSON形态。
4. Vue前端:Axios拦截器、路由守卫与排课页面渲染
4.1 Vue Router路由设计与懒加载
前端路由按业务模块拆分,/login独立成页,其余页面挂在Layout下面,侧边栏、顶栏、面包屑这些只渲染一次。子路由全部用动态import,配合webpack自动分包,教务系统页面数量一多,首屏加载时间能明显降下来。
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/layout/index.vue'), redirect: '/dashboard', children: [ { path: 'student', component: () => import('@/views/student/StudentList.vue') }, { path: 'course', component: () => import('@/views/course/CourseList.vue') }, { path: 'arrange', component: () => import('@/views/arrange/ArrangeList.vue') } ] }, { path: '/login', component: () => import('@/views/Login.vue') } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } }) export default router路由守卫只看token是否存在,适合做登录态拦截。真实场景里管理员和教师登录后可见菜单不同,仅靠这一层挡不住越权,还要在菜单数据返回时按角色过滤,接口侧同步校验角色权限。前端路由守卫只是体验层的拦截,权限的底线始终在后端。
4.2 Axios实例、请求Token与响应统一解包
若依框架前后端分离做得比较早,它的request.js设计思路在教务项目里同样适用:单独封装一个axios实例,baseURL指向/api,请求拦截器负责加token,响应拦截器负责把Result解包。这样Vue组件里拿到的直接是业务数据,省掉每个页面重复解包的逻辑:
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, msg, data } = res.data if (code === 200) return data ElMessage.error(msg || '请求失败') return Promise.reject(new Error(msg)) }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message) return Promise.reject(error) } ) export default requestAuthorization头里Bearer和token之间必须有一个空格,这是JWT的通用格式。401统一在拦截器里处理,清掉localStorage并跳回登录页,比每个页面各自catch一遍干净得多。过期token被后端拒掉后,用户重新登录一次即可,不用手动清浏览器缓存。
4.3 排课页面的二维网格与保存逻辑
排课页面视觉上是一张二维表格,行是节次,列是星期,每个格子背后就是一条course_arrange记录。Vue组件里维护cells对象,用字符串拼接weekDay-period作为key,比二维数组少一层边界判断:
export default { data() { return { weekDays: ['周一', '周二', '周三', '周四', '周五'], periods: ['第1节', '第2节', '第3节', '第4节', '第5节', '第6节', '第7节', '第8节'], cells: {} } }, methods: { async fetchArrange() { const data = await request.get('/admin/arrange/list', { params: { page: 1, size: 100 } }) this.cells = {} data.records.forEach(item => { this.cells[`${item.weekDay}-${item.period}`] = item }) }, async saveCell(weekDay, period) { await request.post('/admin/arrange/save', { weekDay, period, classroom: 'A101' }) this.fetchArrange() } } }注意保存时classroom如果固定写死,排课系统就失去意义。常见的高级做法是点击格子先弹教室选择器,已被占用的时间片置灰不可选;前端只做提示,真正拦住冲突的是后端Service校验加数据库唯一索引。
前端页面与后端接口对应关系:
| 路由路径 | 页面组件 | 后端接口 |
|---|---|---|
| /login | Login.vue | POST /api/auth/login |
| /student | StudentList.vue | GET /api/student/list, DELETE /api/student/{id} |
| /course | CourseList.vue | GET /api/course/list |
| /arrange | ArrangeList.vue | GET/POST /api/admin/arrange/* |
4.4 Vue开发环境的proxy代理
前端开发服务器跑在8081,后端在8080,直接请求就是跨域。最简单的方式不是在后端写CorsConfig,而是让vue-cli把/api代理到后端:
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }changeOrigin: true会把请求头里的Host改写成目标地址,后端依赖Host做判断时才不会出错。开发环境的proxy解决跨域问题,生产环境换成nginx做反向代理,把/api转发到后端服务,前端静态文件由nginx直接托管。
5. maven-wrapper构建封装与部署排错
5.1 mvnw.cmd与maven-wrapper.jar的作用
项目自带的mvnw.cmd和maven-wrapper.jar是两个容易被忽略但价值很高的文件。它们的作用是锁定Maven版本:无论本机装的是Maven 3.5还是3.9,执行mvnw.cmd都会按.mvn/wrapper/maven-wrapper.properties里指定的版本来运行。团队多人协作开发时,这个机制能消掉“你机器上编译通过,我这里报错”的经典问题:
./mvnw clean package # Linux / macOS mvnw.cmd clean package # Windows CMD打包成功后在target目录生成edu-admin.jar,配合java -jar启动;前端单独执行npm run build,产物dist目录交给nginx。开发环境两个服务分开跑,线上再用nginx把静态资源和后端API收口,是这类前后端分离项目最常见的部署方式。
5.2 Vue打包后资源路径与布局异常
打包后布局错乱、样式全丢,多数是静态资源路径没配。publicPath默认是/,如果前端部署在域名子路径下,所有js、css都从根路径找,自然404。改成相对路径:
module.exports = { publicPath: './', outputDir: 'dist' }publicPath: './'只影响资源加载路径,不影响路由。路由用了createWebHistory()后,部署到nginx刷新非登录页会404,因为nginx找不到对应的物理文件,需要加try_files $uri $uri/ /index.html;兜底。改完配置后重新npm run build,再让nginx重新加载配置。
5.3 高频异常速查表
| 异常现象 | 原因 | 处理方式 |
|---|---|---|
| 启动报time zone value unrecognized | JDBC连接串缺少时区参数 | 加serverTimezone=Asia/Shanghai |
| Vue请求一直404 | proxy没配或publicPath不对 | 检查vue.config.js与nginx配置 |
| 验证码图片能出但校验失败 | 跨域Session未共享或Redis未接入 | 验证码迁移到Redis并设置过期时间 |
| Excel导入后学号变科学计数法 | double转字符串丢失精度 | 用BigDecimal.toPlainString()处理 |
| 前端传page=1但数据对不上 | PageRequest从0开始计页 | Controller层统一page - 1 |
排查这类问题,先看后端日志里有没有SQL输出。没有SQL说明请求根本没到Controller,问题在网关、代理或跨域层;有SQL但结果不对,再检查JPA方法名解析和分页参数。按这个顺序定位,比反复刷新页面猜原因有效率得多。
本文还有配套的精品资源,点击获取