简介:这是一份基于Java的教务查询系统源码包,面向正在学习SSM框架整合的Java后端开发者,适合作为课程设计或新手练手项目。项目使用Spring、SpringMVC、Mybatis搭建后端,Shiro负责权限控制,C3P0管理数据源,配合log4j日志与Bootstrap前端页面,覆盖了从数据库表设计到Web交互的完整开发链路。压缩包共271个文件,大小约35.32MB,包含55个Java源码、76个编译后的class文件、42个XML配置、31个依赖Jar包以及28个JSP页面,另有MySQL建表SQL脚本、properties配置和docx说明文档等。源码与配置分离、页面模块清晰,适合对照学习SSM整合细节、Shiro过滤器配置以及Mybatis逆向工程生成的实体与Mapper用法。目前已有411人学习下载,通过该项目可掌握教务系统中课程、教师、学生等核心模块的增删改查实现,理解登录鉴权与角色管理逻辑,同时还能参考Bootstrap搭建的简洁管理界面,是一份实用且有代表性的JavaWeb练手资料。
1. 教务系统的Java实现:先搞清楚需求边界再动手
教务系统基本是Java后端从业者遇到最多的课程设计选题之一,也是教学管理类项目里最有代表性的业务系统。它表面上是学生、教师、课程、成绩四张表的增删改查,真做起来才会碰到动态权限、选课并发、成绩批量导入导出、课表冲突校验这些麻烦事。这篇文章顺着一个Java开发的教务系统应该怎么做来展开:技术栈怎么选、核心表怎么设计、选课和成绩模块的代码怎么写、哪些坑是教程不会告诉你的。适合正在做课程设计、接手公司内部教学管理需求、想在面试里把项目讲出亮点的读者。
2. 技术选型与工程结构:Spring Boot + MyBatis-Plus搭出教务系统骨架
2.1 技术栈选择:教务系统为什么用这套组合
教务系统是典型的管理信息系统,特点是CRUD密集、报表需求多、权限层级明确、并发热点集中在选课和成绩上报。常见做法是Spring Boot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8.0,前端配Vue 3和Element Plus,前后端分离开发。有些课程设计会要求SSM,但Spring Boot本质上是SSM的再封装,部署和配置都更省事,直接选它不会错。
ORM选MyBatis-Plus而不是JPA,核心原因是教务系统的查询条件组合非常多样:学生列表要按年级、班级、学号、姓名、状态过滤,成绩列表要按学期、课程、教师、分数段过滤。MyBatis-Plus的LambdaQueryWrapper可以动态拼接SQL,配合分页插件,绝大多数查询不用写XML。JPA在复杂查询上要写Specification,SQL不可见,一旦慢查询出现,排查成本明显更高。Spring Data JPA的优势在领域模型复杂、关联关系多的系统,但教务系统里报表和过滤才是主场景,MyBatis-Plus更顺手。
数据库表的核心拆分原则是用户与档案分离:user表只存登录凭证和人员类型,student、teacher作为各自的档案表;课程、选课、成绩三个核心表必须独立;班级、学院、学期这类基础数据单独建表。我第一次做的时候把用户信息和学生信息混在一张表里,后面权限模块一加,字段越来越多,连查询都开始别扭。现在的原则很简单:登录认出你是谁,档案表存你的业务属性。
2.2 数据库设计:核心表与字段的红线
这种系统最容易翻车的点是选课关系表设计不对。course_selection表至少要包含student_id、course_id、semester_id、status四个字段。status用数字语义:0已选、1已退、2已结课,不要直接DELETE物理删除,因为教务系统需要保留选课历史,期末成绩要回溯。我踩过物理删除选课记录导致成绩关联查不到的坑,所以现在一律逻辑删除。
成绩表要有course_id、student_id、score、gpa、remark,并加唯一索引(student_id, course_id, semester_id),防止同一个学期同一门课出现两条记录。很多课程设计在这个表上不加唯一索引,批量导入成绩时一跑就出重复数据,这是很典型的扣分点。
核心表字段清单
| 表名 | 关键字段 | 索引设计 |
|---|---|---|
| user | id, username, password, user_type | unique(username) |
| student | id, user_id, name, class_id, enrolled_year | idx_class_id |
| teacher | id, user_id, name, title, dept_id | idx_dept_id |
| course | id, name, teacher_id, credit, capacity, week_info | idx_teacher_id |
| course_selection | id, student_id, course_id, semester_id, status | unique(student_id, course_id, semester_id) |
| score | id, student_id, course_id, score, gpa, semester_id | unique(student_id, course_id, semester_id) |
这是我常用的最小集。字段不要追求“通用”而贪多,像gender、phone这类跟业务弱相关的字段,建表时加了,后面也就是填个默认值,徒增维护成本。另外提醒一点:week_info不要用单个字符串存“1-16周,周一3-4节”,解析课表冲突时非常痛苦,拆成week_day、start_section、end_section、start_week、end_week更合适。
2.3 工程结构划分与第一个可运行接口
包结构建议按业务模块拆,不按技术层拆。com.xxx.education下放module、common、config三个根包:module里是每个业务模块的controller/service/mapper/entity;common放统一返回体、异常处理器、工具类;config放MyBatis-Plus分页插件、跨域配置、拦截器。按技术层拆(controller包、service包)在模块少的时候看着清爽,模块一多就成了垃圾堆,这是我带新人时反复强调的点。
先贴一个学生模块的分页查询,这是整个系统最基础的接口。
@RestController @RequestMapping("/api/student") public class StudentController { @Resource private StudentService studentService; @GetMapping("/page") public Result<Page<StudentVO>> page(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String name, @RequestParam(required = false) String className) { Page<Student> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(name), Student::getName, name) .like(StringUtils.hasText(className), Student::getClassName, className) .orderByDesc(Student::getEnrolledYear); Page<Student> result = studentService.page(page, wrapper); return Result.ok(convertToVO(result)); } }这段代码的关键在于LambdaQueryWrapper的条件带上了布尔判断:name为空时like条件直接不拼进SQL。很多初学者习惯先把条件字符串拼出来,再执行查询,很容易因为空值拼出WHERE name LIKE '%%'这种扫全表的查询。orderByDesc按入学年份倒序排,让新生出现在列表前面,符合教务系统的展示习惯。
这里有两个参数值得注意。pageNum和pageSize用了默认值,生产环境一定要在拦截器里限制pageSize最大不超过100,否则有人循环翻页可以把数据库打满。另一个参数是name和className是模糊匹配,课程设计里无所谓,但如果将来数据量过百万,前模糊匹配会导致索引失效,届时要做成左前缀或者引入搜索引擎。分页插件记得在config里注册,否则page方法不生效,返回的total永远是0。
3. 核心业务落地:选课冲突、成绩录入与动态权限的代码写法
3.1 选课模块:用事务加行锁保住数据一致性
选课是教务系统里最能体现数据一致性的模块,也是Java面试题里最容易聊深的地方。最简需求是:学生在选课期间选择一门课,课程有容量上限,超过容量不能再选。很多课程设计的写法是先查课程剩余容量,大于0就插入选课记录。这个逻辑单用户测试没问题,并发选课时会出现两个请求同时查到剩余容量为1,然后都插入成功,导致超员。这就是分布式系统里经典的“检查与执行分离”竞态。
正确做法是让数据库来把关,而不是靠Java代码里的if判断。常见方案是执行选课时先对课程记录加行锁,再查容量,再插入。用MyBatis-Plus可以手写一条带FOR UPDATE的查询语句:
@Mapper public interface CourseMapper extends BaseMapper<Course> { @Select("SELECT * FROM course WHERE id = #{id} FOR UPDATE") Course selectByIdForUpdate(Long id); } @Override @Transactional(rollbackFor = Exception.class) public boolean selectCourse(Long studentId, Long courseId, Long semesterId) { // 行锁:锁住课程记录,防止并发选课读到同一个剩余容量 Course course = courseMapper.selectByIdForUpdate(courseId); int selectedCount = courseSelectionMapper.selectCount( new LambdaQueryWrapper<CourseSelection>() .eq(CourseSelection::getCourseId, courseId) .eq(CourseSelection::getStatus, 0)); if (selectedCount >= course.getCapacity()) { throw new BusinessException("该课程已满员"); } CourseSelection selection = new CourseSelection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setSemesterId(semesterId); selection.setStatus(0); return courseSelectionMapper.insert(selection) > 0; }这段代码的逻辑顺序很关键:FOR UPDATE锁住课程行之后,后续的selectCount和insert必须都在同一个事务里,其他事务要更新这条课程记录就必须等锁释放。@Transactional(rollbackFor = Exception.class)保证插入失败时整体回滚,不会出现“容量没减但选课记录已插入”的中间状态。rollbackFor这里必须写,因为Spring默认只对RuntimeException回滚,而BusinessException是我的自定义异常,继承自RuntimeException,如果只写@Transactional,遇到受检异常就不会回滚。
参数上有个重要坑:行锁生效的前提是MySQL使用InnoDB引擎,且查询走了索引。id是主键没问题,但如果按course_code这种业务字段加锁,务必确认该字段有唯一索引,否则FOR UPDATE会退化成锁表,选课高峰期整个课程表锁住,系统直接卡死。另一个参数是事务隔离级别,默认的REPEATABLE READ可以配合行锁使用,不需要为了选课改成READ COMMITTED,但要清楚REPEATABLE READ下间隙锁可能扩大锁范围,影响并发度。
悲观锁是最常见做法,不是唯一做法。选课量极大时可以在Redis里预扣库存做前置保护,但最终一致性必须靠数据库兜底,不要把Redis当唯一防线,否则Redis和MySQL数据不一致时,你会被“学生明明选了课,成绩单却查不到”这种事烦死。
3.2 成绩模块:批量录入的幂等设计与校验
成绩录入是另一个高频需求。教师端通常是下载一个班级名单模板,填完分数再上传。这个功能最怕两件事:一是Excel解析乱码,二是同一份文件重复上传导致成绩重复。
Excel处理在Java生态里最常用的是阿里开源的EasyExcel,底层是POI但做了内存优化。为什么不用原生POI?因为POI的XSSFWorkbook会把整个文件加载进内存,一张几万行的成绩表就能吃掉几百MB堆内存,在普通服务器上很容易OOM。EasyExcel走SAX模式流式读取,峰值内存可以控制在几十MB以内,这是大量项目实测的结果。
上传接口的代码逻辑一般是这样:
@PostMapping("/import") public Result<String> importScore(MultipartFile file, @RequestParam Long courseId, @RequestParam Long semesterId) { // 生成文件指纹,用于幂等校验 String md5 = DigestUtils.md5DigestAsHex(file.getBytes()); String redisKey = "score:import:" + md5; Boolean first = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, "1", Duration.ofHours(2)); if (Boolean.FALSE.equals(first)) { throw new BusinessException("该文件已导入过,请勿重复提交"); } List<ScoreImportRow> rows = new ArrayList<>(); EasyExcel.read(file.getInputStream()) .head(ScoreImportRow.class) .registerReadListener(new AnalysisEventListener<ScoreImportRow>() { @Override public void invoke(ScoreImportRow data, AnalysisContext context) { // 逐行校验学号、分数范围 if (data.getStudentNo() == null || data.getScore() < 0 || data.getScore() > 100) { throw new BusinessException("第" + context.getCurrentRowNum() + "行数据不合法"); } rows.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) { } }) .sheet() .doRead(); scoreService.batchSave(courseId, semesterId, rows); return Result.ok("导入成功,共" + rows.size() + "条"); }这段代码里有两个容易被忽略的细节。第一个是幂等:用文件内容MD5做Redis的setIfAbsent,同一个文件两次提交会被拦截,这是解决“教师手抖点两次上传”的标准做法。如果在不带Redis的课程设计环境里,可以用数据库的唯一索引兜底:score表加唯一键(student_id, course_id, semester_id),重复插入直接抛DuplicateKeyException。第二个是EasyExcel.read的链式调用:head指定表头映射实体,registerReadListener注册逐行回调,不会一次性把整个Excel加载进内存。
批量保存时不要对每条成绩单独insert,常见优化是用MyBatis-Plus的saveBatch,或者自己写INSERT INTO ... VALUES (...),(...)批量语句。单批大小一般控制在500行左右,太大反而因为SQL过长和主从同步延迟导致插入变慢。批量之前先做内存去重,同一个学生在一份文件里出现两行时,以最后的分数为准。
3.3 权限模块:登录后能看到的菜单和按钮
教务系统的用户分学生、教师、管理员三类,权限需求通常不是简单的三套页面,而是“角色-菜单-操作按钮”的组合。任课教师能看到自己课程的名单和成绩录入入口,教学秘书能看到所有课程但不能改成绩,学生只能看自己的课表和成绩。这类需求落地成动态菜单和按钮级权限。
后端实现上,登录成功后在JWT里只放userId和roleCode,菜单和按钮权限不放进Token——因为角色权限调整时,旧Token不应该继续生效,放进去会导致权限变更要等Token过期。权限数据每次请求时从Redis缓存读取,强一致要求不高时这是一种性价比很高的方案。
public class AuthInterceptor implements HandlerInterceptor { @Resource private StringRedisTemplate stringRedisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { throw new BusinessException(401, "未登录"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); String permissionKey = "user:perm:" + userId; // 用角色编码缓存权限清单,权限变更时删除对应key String perms = stringRedisTemplate.opsForValue().get(permissionKey); if (perms == null) { perms = permissionService.loadUserPerms(userId); stringRedisTemplate.opsForValue().set(permissionKey, perms, Duration.ofMinutes(30)); } if (!perms.contains(request.getRequestURI())) { throw new BusinessException(403, "无权限访问"); } return true; } }这个拦截器的核心思想是把登录校验和权限校验合在一处。JwtUtil.parseToken解析出的userId去Redis取权限清单,权限数据变更时删除user:perm:{userId}这个key,权限就能在短时间内失效。perms.contains(request.getRequestURI())是简化写法,生产环境建议把权限存成Set再做匹配,避免URI子串匹配误判,比如/score/list和/score/list/detail之类容易被错误包含。
权限模型的数据结构一般是user、role、menu三张主表和user_role、role_menu两张关联表。menu里用一个parent_id字段做树形结构,页面菜单按角色查出来组装成树返回给前端;按钮权限用menu的type字段区分:1是菜单,2是按钮。树形查询在MyBatis-Plus里可以用递归查询或者一次性查出后内存组装,数据量小的时候内存组装更简单。
这个方案有个天然边界:权限一变,已登录用户的权限最多还有30分钟缓存存活期。如果对实时性要求高,比如禁用某教师账号后要立刻生效,就得在权限变更时主动清除相关用户的缓存key,或者在Redis里存一个全局版本号,权限变更时版本号加1,每次请求比版本号判断缓存是否过期。我在实际项目里用的是主动清除方案,清晰也好解释。
4. 教务系统踩坑排查:并发超选、POI内存溢出和事务失效
4.1 并发选课人数超过课程容量
现象:压测时200个学生同时选同一门容量100的课,最终数据库里选课记录变成130条,容量校验形同虚设。
原因:接口先查剩余容量再插入,两个操作之间有时间窗口。并发请求都读到剩余容量为1,然后一起通过if校验,插入多条记录。这是检查与执行分离导致的竞态条件,属于Java后端并发场景里的典型问题。
解决:给课程表加行锁SELECT ... FOR UPDATE,让容量判断和插入在同一个事务里串行执行。同时给course_selection(student_id, course_id, semester_id)加唯一索引,即使锁意外失效,第二次插入也会因为唯一键冲突而失败,起到兜底作用。两层防护都以数据库为准,比在Java代码里加synchronized可靠得多,因为synchronized只对单机实例有效。
4.2 EasyExcel导入偶尔出现中文乱码
现象:本地Windows开发的CSV文件测试正常,部署到Linux服务器后,导入的中文全部变成乱码。
原因:CSV文件在Windows下默认是GBK编码,EasyExcel默认按UTF-8解析。开发环境IDE常常自动把文件转成UTF-8,所以测不出来;生产环境拿到的原始文件就是GBK。这类问题属于典型的运行环境差异。
解决:CSV读取时显式指定编码为GBK,或者要求统一上传.xlsx格式,因为XLSX内部固定用UTF-8存储,不存在乱码问题。排查时先把文件字节转成十六进制,看中文字符对应的编码值,再针对性指定字符集,不要猜。这个坑是血泪经验,第一次遇到时我折腾了一下午。
4.3 同类内调用事务方法导致事务失效
现象:在Service里新增一个公开方法,直接内部调用了带@Transactional的另一个方法,异常时数据没回滚。
原因:@Transactional生效依赖Spring AOP代理。同类内部调用走的是this引用,不是代理对象,事务注解被直接跳过。这是Spring面试题里高频出现的“事务失效场景”,实际项目里一旦遇到,表现就是数据错乱但日志无异常。
解决:把需要事务的方法拆到另一个Service类里,让外部调用走代理;或者通过ApplicationContext.getBean拿代理对象再调用。实操中我更推荐前者,因为拆类的同时也把职责拆清楚了。还要注意:事务方法内部不要用try-catch吞掉异常,Spring默认只回滚RuntimeException,吞了异常等于告诉Spring“处理好了”,自然不会回滚。如果业务要求catch并记录日志,记得catch后重新抛出RuntimeException。
4.4 跨域配置导致前端请求全部失败
现象:Vue前端本地调试时,接口请求全部失败,浏览器控制台报CORS错误。
原因:前端跑在localhost:5173,后端跑在localhost:8080,端口不同即跨域。后端没正确配置CORS头,或者配置了但被Spring Security的过滤器链先拦截,OPTIONS预检请求返回401。
解决:在Spring Boot里用全局配置类,注意allowCredentials(true)时不能直接用allowedOrigins(""),这是很多课程设计里报错的原因。用allowedOriginPatterns("")配合allowCredentials使用。如果项目里集成了Spring Security,还需要在Security配置里放行OPTIONS请求,否则预检会先挂在认证上。下面这段是我常用的配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }maxAge(3600)表示预检请求结果缓存一小时,可以减少浏览器频繁发送OPTIONS请求的开销。allowedMethods里必须包含OPTIONS,否则预检请求直接失败。
4.5 时间字段差8小时的时区问题
现象:后端存到MySQL的时间,前端查出来比实际时间快或慢了8小时。
原因:MySQL连接串没统一指定serverTimezone,加上Jackson序列化默认按JVM时区输出。本地开发机时区正好是东八区,部署到云服务器后服务器时区是UTC,日期就全偏了。时区是后端项目里最爱给人挖坑的黑匣子。
解决:在JDBC连接串里显式写serverTimezone=Asia/Shanghai,并统一Jackson时区。
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8 spring.datasource.url=jdbc:mysql://localhost:3306/education?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai时间字段建议统一用DATETIME,不要用TIMESTAMP。TIMESTAMP有2038年问题和UTC转换行为,会给排错增加一个黑匣子。这个坑在课程设计答辩现场特别常见,主动提一句“我统一了时区配置”,会让人觉得你处理过真实环境问题。
5. 进阶技巧:把成绩导出接口从慢查询优化到流畅导出
教务系统做到后期,最大的体验瓶颈往往不是增删改查,而是导出。一个学期的全年级成绩单,数据量几万行,接口直接返回JSON再让前端生成Excel,浏览器经常卡死。这里讲一个我常用的服务端导出改造思路。
第一步,导出不走列表分页接口,单独开一个GET /api/score/export,查询条件沿用成绩列表的筛选参数。不要复用列表接口再翻页拼接,那样会产生N次查询,VO转换也浪费。直接写一条流式查询或者一次查全量,重点是避免多次数据库往返。
第二步,用EasyExcel的write方法做流式输出,OutputStream直接写到HttpServletResponse的响应流,而不是先构造Workbook再整体写出去。原生POI的XSSFWorkbook会把所有单元格留在内存里,几万行就吃满内存;EasyExcel会分批把数据刷出,内存曲线平滑很多,这是它相比原生POI最核心的优势。
第三步,超过两万行时做异步化。接口同步返回会让用户傻等,常见做法是提交导出任务到线程池,前端轮询任务状态,完成后提供下载链接。这个方案的代价是要加一张export_task表,记录任务状态、筛选参数、结果文件路径,但体验提升非常明显。线程池参数要单独配置,不要用默认的公共池,避免导出任务把业务接口的线程占满。
我现在的习惯是:报表功能上线前,一定先拿全量数据压一遍导出接口,观察JVM内存曲线和SQL执行计划。如果慢,先看WHERE条件有没有走索引;如果内存涨得离谱,检查是不是引入了Excel全量对象。这两类问题在监控里一眼就能看出来,不要一上来就加服务器配置,那是最后一招。做完导出优化后记得翻一遍慢查询日志,至少做到“学生多、成绩多、导出不卡”。希望帮到你。
本文还有配套的精品资源,点击获取