☰
Java教务系统开发:从高并发CRUD到Spring Boot事务与MyBatis-Plus避坑指南
2026/10/9 6:22:00 网站建设 项目流程

简介:基于 Java 开发的教务查询系统,定位为 SSM 整合练手项目,面向 Java 后端初学者、毕业设计学生与课程设计人群,通过课程、选课、登录鉴权等典型模块,帮助读者理解企业级分层开发的基本流程。压缩包共 271 个文件,约 35.32MB,包含 55 个 Java 源码、76 个 class 编译文件、42 个 XML 配置、31 个 jar 依赖包、28 个 JSP 页面,以及 SQL 脚本、CSS/JS 等前端资源。项目技术栈为 Spring、SpringMVC、MyBatis、Shiro、C3P0,前端采用 Bootstrap;从数据库建表脚本到 Controller、Service、Mapper 和 JSP 页面均有完整代码,业务分层清楚,且配置、源码、页面一一对应,便于边看边调试。学习者可以对照掌握 IOC 容器管理、SpringMVC 请求流转、MyBatis 映射编写、Shiro 登录鉴权与权限控制,以及 log4j 日志输出等关键点;压缩包内的 2 个 SQL 脚本可直接初始化数据库,降低环境搭建门槛。已有 412 人学习浏览,适合作为课程设计或实训项目的基础,快速扩展成绩管理、教师管理等功能。

1. 基于 java 开发的教务系统:先把它当成一个高并发 CRUD 系统来设计

基于 java 开发的教务系统是Java课程设计里最常见的题目,但也是翻车率最高的题目。我见过太多项目把选课写成“先查剩余人数再插入”,一压测就超卖;成绩用float存,期末总分差出两分;排课时间存成字符串,冲突检测只能靠肉眼看。教务系统本质是一个带并发写、权限分明、数据精度要求高的CRUD系统,不是简单的增删改查。这篇文章从数据库模型、Spring Boot + MyBatis-Plus 服务端骨架,到五个高频踩坑,按可复现的方式走一遍。适合正在做课程设计或准备Java实习面试的工程师参考。

2. 教务系统的核心表结构:先定表和关系,再写实体类

2.1 学生、课程、选课是三个对象,不是三列字段

很多课程设计选课表做得像Excel宽表:一行数据包含学生姓名、学号、课程名、教师、成绩、学期。演示时点几下没问题,一旦要查“张三这学期选了几门课”“某门课报名多少人”,SQL就得写一堆 distinct,而且同一学生重复选课时没有数据库层面的拦截。

正确做法是先画对象关系。学生和课程是多对多,必须拆一张选课记录表;课程和开课又是“类与实例”的关系,也要拆开。这里的课程就像Java里的Class,开课计划就像Class里 new 出来的对象。这种思路其实就是面向对象编程java里的“高内聚低耦合”:每个业务动作落在唯一一张表上,不靠复制字段来满足查询。

核心表我一般固定五张:

表名职责关键字段
student学生档案student_no, name, dept_name, enroll_year
course课程目录course_code, course_name, credit, class_hours
semester学期定义semester_no, start_date, end_date
course_offering开课计划course_code, semester_id, teacher_name, weekday, start_section, end_section, capacity, selected_count
course_selection选课记录student_id, offering_id, status, score, select_time

选课记录不是直接关联 course_id,而是关联 offering_id,这样才能区分同一门课春季和秋季两个教学班的成绩。开课计划上冗余了 selected_count,选课时在事务里更新,后面会讲锁。

2.2 用 MyBatis-Plus 实体类对齐建表 SQL:注解、主键策略与数据类型

很多人搜“mybatisplus根据java实体类生成创建表的sql语句”,期望有一个按钮把实体类转成 CREATE TABLE。实际上 MyBatis-Plus 没有这个功能,常见做法是实体类注解和手工 DDL 双写,但保证命名一致。我一般先写实体类,再按注解生成建表脚本。

学生实体类:

import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("student") public class Student { @TableId(type = IdType.ASSIGN_ID) private Long id; @TableField("student_no") private String studentNo; @TableField("name") private String name; @TableField("dept_name") private String deptName; @TableField("enroll_year") private Integer enrollYear; @TableField("created_at") private LocalDateTime createdAt; }

逻辑说明:@TableName 指定表名,@TableId 设置主键,IdType.ASSIGN_ID 表示用 MyBatis-Plus 内置雪花算法生成 Long 主键。如果课程设计用 MySQL 自增,改 IdType.AUTO 就行。@TableField 负责把 Java 的驼峰属性映射到下划线列名。注意 createdAt 在数据库里叫 created_at,如果没写 @TableField,MyBatis-Plus 默认也会做下划线转换,但显式写出来更清楚。

课程和开课计划实体类:

@Data @TableName("course") public class Course { @TableId(type = IdType.INPUT) private String courseCode; @TableField("course_name") private String courseName; @TableField("credit") private BigDecimal credit; @TableField("class_hours") private Integer classHours; } @Data @TableName("course_offering") public class CourseOffering { @TableId(type = IdType.ASSIGN_ID) private Long id; @TableField("course_code") private String courseCode; @TableField("semester_id") private Long semesterId; @TableField("teacher_name") private String teacherName; @TableField("weekday") private Integer weekday; @TableField("start_section") private Integer startSection; @TableField("end_section") private Integer endSection; @TableField("capacity") private Integer capacity; @TableField("selected_count") private Integer selectedCount; }

参数说明:course 表主键是业务主键课程代码,用 IdType.INPUT,不自动生成。credit 用 BigDecimal,因为学分可能带小数(比如 1.5)。course_offering 的 weekday、start_section、end_section 都约定用数字,这是为了排课冲突检测。capacity 和 selected_count 用 Integer,选课人数不会超过 int 范围。

选课记录表:

@Data @TableName("course_selection") public class CourseSelection { @TableId(type = IdType.ASSIGN_ID) private Long id; @TableField("student_id") private Long studentId; @TableField("offering_id") private Long offeringId; @TableField("status") private Integer status; // 1已选 2退选 3成绩已录 @TableField("score") private BigDecimal score; @TableField("select_time") private LocalDateTime selectTime; }

逻辑说明:status 字段比布尔值更实用,因为选课记录会经历“已选 → 退选”或“已选 → 成绩已录”,不是只有两种状态。score 允许为空,成绩录入后才有值。这里最容易被忽略的是唯一约束,建表时必须加:

CREATE TABLE course_selection ( id BIGINT PRIMARY KEY COMMENT '主键', student_id BIGINT NOT NULL COMMENT '学生ID', offering_id BIGINT NOT NULL COMMENT '开课计划ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1已选 2退选 3成绩已录', score DECIMAL(5, 2) DEFAULT NULL COMMENT '成绩', select_time DATETIME NOT NULL COMMENT '选课时间', KEY idx_student (student_id), KEY idx_offering (offering_id), UNIQUE KEY uk_student_offering (student_id, offering_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '选课记录表';

参数说明:score 用 DECIMAL(5,2),最大 999.99,成绩够用,不要用 float。UNIQUE KEY 是并发防重的兜底,Service 层先查再插可能被并发穿透。调试时可以在 application.yml 里打开 SQL 日志:

mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

提示:如果实体类字段和表列名不一致,启动不会报错,但查询会把这列当成 null。遇到这种情况先看控制台 SQL,再回头比对 @TableField。

2.3 排课与教室冲突:用数字节次区间代替“周一 3-4 节”字符串

我见过最难受的排课设计是把上课时间存成“周一 3-4节”,冲突检测先 split 再解析汉字,代码又臭又长。换成 weekday、start_section、end_section 三个数字后,判断两条课是否冲突就是两个区间是否重叠:

public static boolean isOverlap(CourseOffering a, CourseOffering b) { return a.getWeekday().intValue() == b.getWeekday().intValue() && a.getStartSection() <= b.getEndSection() && b.getStartSection() <= a.getEndSection(); }

逻辑说明:start <= 对方的 end 且 对方的 start <= 自己的 end,这就是闭区间重叠公式。用这个逻辑做教室冲突查询也容易:

SELECT * FROM course_offering WHERE classroom = #{classroom} AND weekday = #{weekday} AND start_section <= #{endSection} AND end_section >= #{startSection};

参数说明:如果要区分单双周,再加一个 week_type 字段;要检查老师冲突,把 classroom 替换成 teacher_id 或 teacher_name。把时间拆成数字,这个 SQL 才能在索引上正常走范围查询,字符串节次做不到。

3. Spring Boot + MyBatis-Plus 服务端构建:分层、权限、锁与事务

3.1 工程分层与依赖:Controller 不写 SQL,SQL 不进 Service

教务系统后端我用的是 controller / service / mapper / entity / common 五层。Controller 只做参数接收和鉴权,Service 写业务规则,Mapper 只处理 SQL。这样最后给课程设计画模块图也好讲,代码评审也好过。

pom.xml 核心依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>

逻辑说明:mybatis-plus-boot-starter 3.5.x 是主流,它帮你省掉 MyBatis 和 SqlSessionFactory 的手写配置。如果换 Spring Boot 3,MySQL 驱动坐标要改为 com.mysql:mysql-connector-j,Java 版本也要求 JDK 17 以上,否则启动报 javax 包找不到。

Mapper 接口很简单:

public interface CourseSelectionMapper extends BaseMapper<CourseSelection> { }

Service 里查询用 LambdaQueryWrapper,避免字符串字段:

LambdaQueryWrapper<CourseSelection> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(CourseSelection::getStudentId, userId) .eq(CourseSelection::getStatus, 1); List<CourseSelection> list = courseSelectionMapper.selectList(wrapper);

参数说明:eq 的第一个参数是方法引用,编译期能校验字段,比 QueryWrapper 传字符串硬拼安全得多。orderBy 和 in 都是类似表达,不认识的字段会在编译期直接报错。

3.2 分页插件:不配置 PaginationInnerInterceptor,Page 等于白写

教务系统的课程列表和成绩列表都要分页。MyBatis-Plus 不配置分页插件时,selectPage 会把 Page 当成普通参数,直接全表查询。配置一下:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }

参数说明:DbType.MYSQL 指定方言,生成 LIMIT 语句;setMaxLimit(500) 限制单页最大条数。再写 Controller:

@GetMapping("/courses") public Page<Course> listCourses(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return courseMapper.selectPage(new Page<>(page, size), new LambdaQueryWrapper<Course>().orderByAsc(Course::getCourseCode)); }

注意 selectPage 返回的 Page 对象里包含 records、total、current 等字段。如果前端需要更干净的 JSON,再在 Service 里包装成 Map。

3.3 用 JWT 登录和角色鉴权:一个 Token 区分学生、教师、管理员

教务系统权限最烦的是接口要对不同角色开放。用 Spring Security 功能全但配置量大,我一般先上轻量 JWT 方案。登录接口发 token:

public class JwtUtil { private static final String SECRET = "change-this-secret-key"; public static String createToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 120)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }

逻辑说明:token 里放 userId 和 role,过期时间两小时。HS256 是对称密钥,secret 建议从配置文件读,不要硬编码。前端请求头带 Authorization: Bearer xxx,拦截器里解析:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } Claims claims = JwtUtil.parseToken(token.substring(7)); request.setAttribute("currentUserId", claims.get("userId")); request.setAttribute("currentRole", claims.get("role")); return true; } }

然后在需要鉴权的方法上打注解:

@RequireRole({"TEACHER"}) @PostMapping("/score") public Result recordScore(...) { ... }

拦截器里判断方法上的注解和当前角色是否匹配,比每个方法里写 if 干净。如果后面要行级权限,比如教师只能看自己教的课,在 Service 里再加“当前用户ID是否等于开课计划的 teacherName”的判断即可。

3.4 选课和成绩录入:事务里放锁,先判容量再插入

选课接口的并发控制是核心。Service 方法标 @Transactional,用悲观锁锁住开课计划行:

@Transactional public void selectCourse(Long studentId, Long offeringId) { CourseOffering offering = offeringMapper.selectByIdForUpdate(offeringId); if (offering.getSelectedCount() >= offering.getCapacity()) { throw new BusinessException("课程已满"); } CourseSelection cs = new CourseSelection(); cs.setStudentId(studentId); cs.setOfferingId(offeringId); cs.setStatus(1); cs.setSelectTime(LocalDateTime.now()); courseSelectionMapper.insert(cs); offeringMapper.increaseSelectedCount(offeringId); }

Mapper 自定义 SQL:

SELECT * FROM course_offering WHERE id = #{id} FOR UPDATE UPDATE course_offering SET selected_count = selected_count + 1 WHERE id = #{id}

逻辑说明:FOR UPDATE 让并发请求在数据库层面排队,拿到锁后重新读最新 selected_count,事务提交才释放锁,所以不会超卖。注意如果先插入选课记录再更新人数,遇到唯一索引冲突会抛异常,事务回滚后人数不会错,但业务上先判容量更好。

成绩录入事务:

@Transactional public void recordScore(Long selectionId, BigDecimal score) { CourseSelection selection = courseSelectionMapper.selectById(selectionId); if (selection == null || selection.getStatus() != 1) { throw new BusinessException("选课记录不存在或已退选"); } if (score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0) { throw new BusinessException("成绩必须在0到100之间"); } selection.setScore(score); selection.setStatus(3); courseSelectionMapper.updateById(selection); }

参数说明:status == 1 表示可录状态,录入后置为 3,避免重复写覆盖。BigDecimal 比较用 compareTo,不要用 equals。这里的异常要抛出去,不能 catch 后吞掉,否则 @Transactional 看不到异常就不会回滚,这条在避坑里是很重要的教训。

4. 教务系统避坑指南:5 个必踩的坑与排查思路

4.1 选课并发导致已选人数超容量

现象:用 JMeter 开 100 个线程并发选同一门容量为 1 的课,最终选课记录有 3 条,selected_count 变成 3。

原因:Service 里先 select 再 insert 不是原子操作。多个请求同时读到剩余 1,一起插入成功,数据库层面没兜底。

解决:用 SELECT ... FOR UPDATE 锁行后再判断容量,并且把方法标 @Transactional。锁必须和事务在同一线程,Spring 事务默认传播级别是 REQUIRED,直接标注解即可。同时保留 course_selection 表上的唯一索引。课程设计阶段用悲观锁足够,不用上 Redis 分布式锁。验证方法很简单:JMeter 并发跑完,查一下 selected_count 是否等于实际选课记录数,不超过 capacity 就算过了。

4.2 成绩总分对不上:float 的二进制误差

现象:录了 89.5 分,列表显示 89.499999;三门成绩相加,总分比手工算少 0.01。

原因:Java 的 float/double 是二进制浮点数,0.1 无法精确表示,成绩计算会累积误差。这个知识点在 Java 基础面试里也是高频题,答到“BigDecimal 和 compareTo”基本不会错。

解决:实体类 score 用 BigDecimal,数据库用 DECIMAL(5,2)。运算用 add、subtract,不要用运算符。录入成绩时先校验范围,再 setScore,避免把小数字段塞进数据库变成四舍五入的意外。如果你在代码里看到float score = 89.5f;,建议立刻改成BigDecimal score = new BigDecimal("89.5"),用字符串构造而不是传 double。

4.3 排课冲突漏检:只比对开始节次不比对区间

现象:老师周一第 1-2 节和周一第 2-3 节同时被排上,冲突检测通过。

原因:冲突公式写成了 startSection1 == startSection2,忽略了 2 在 1-2 区间内重叠。

解决:区间重叠判断是 start_section <= end_other AND start_other <= end_section。在插入开课计划前校验。教室冲突同理,把 teacher 维度换成 classroom。写一个 isOverlap 方法,用单元测试覆盖边界用例:1-2 vs 2-3、3-4 vs 1-4。这类问题最容易在人工测试时漏掉,因为肉眼只会看“开始节次是否相同”。

4.4 启动失败:JDK 版本和 Spring Boot 版本不匹配

现象:项目启动直接报 NoClassDefFoundError: javax/servlet/... 或 UnsupportedClassVersionError。

原因:Spring Boot 3.x 把 javax 换成了 jakarta,且必须 JDK 17+;你在 JDK 8 下跑 Spring Boot 3,必然崩。这种版本不匹配问题是 java 环境配置里最玄学的,经常是代码没写错,纯粹依赖版本对不上。

解决:先java -version确认 JDK。JDK 8 配 Spring Boot 2.7.x,JDK 17 配 Spring Boot 3.x。如果强制用 JDK 8,只能选 Boot 2.7。确认后清掉本地 Maven 仓库重新下载依赖,避免缓存旧包。再用mvn dependency:tree看是否混入两个版本的 mybatis-plus 或 servlet-api。

4.5 数据库连接池被占满:事务方法吞异常

现象:运行一会儿,日志出现HikariPool-1 - Connection is not available, request timed out after 30000ms,接口全部假死。

原因:事务方法内部 catch 了异常没有重新抛出,Spring 认为事务正常提交,但连接一直被占用;或者手动加了数据库锁没有在 finally 里释放。连接池爆了之后才想起来配置超时,没有后悔药。

解决:事务方法不要吞异常,至少抛出 RuntimeException 或自定义 BusinessException。如果业务要求捕获后继续,也要手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。排查时先看堆栈里哪个线程占用了连接,通常配合 arthas 的 thread 命令看 BLOCKED 状态。另外给 HikariCP 设置合理超时:

spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 3000 connection-init-sql: SELECT 1

参数说明:maximum-pool-size 默认 10,小并发项目可以调到 20;connection-timeout 不要设太大,否则前端等半天才看到超时。connection-init-sql 保证拿到的连接是活的,避免 MySQL 8 小时断开后继续用死连接。

5. 把教务系统从“能跑”变成“敢上线”:三个小习惯

5.1 用 Flyway 管理建表脚本,不要手动去数据库改表

做了数据库改动,直接连 Navicat 改表,过两天同事拉代码跑起来发现表少一列,这种事我遇到过不止一次。Flyway 的做法是把脚本放在 src/main/resources/db/migration 下,文件名是 V1__init.sql、V2__add_unique.sql 这种,应用启动时自动执行,执行过的脚本有记录,不会重跑。这样表结构跟着代码走,不用每次部署都靠人肉提醒。

5.2 给选课接口做一次轻量并发验证

课程设计答辩时最怕被问“并发怎么办”。你可以准备一段简单的 JMeter 压测:100 个线程同时选同一门课,观察最终已选人数是否等于容量,选课记录是否没有重复。如果没有 JMeter,用curl并发跑也可以,但不如 JMeter 能看响应时间。验证目的不是证明系统能扛十万并发,而是证明数据库约束和事务边界真的起作用。

5.3 统一返回与异常脱敏,别把 SQL 堆到前端

统一返回值Result.success(data)/Result.fail(code, msg),而不是把 MyBatis-Plus 的异常堆栈直接返回。异常信息里可能包含 SQL 语句、表名、参数,对调试有用但对攻击者也有用。生产环境把 SQL 异常记录 error 日志,对外返回“系统繁忙”。这个习惯我在第一次上线后吃了亏才补上,属于黑匣子也不该打开的典型场景。

说回习惯:我现在做任何 Java 后端项目,涉及数值计算一律 BigDecimal,涉及并发写一律先想唯一索引和锁,涉及数据库变更一律落脚本进 git。这个管理方式,项目放到手上心里才不慌。如果你正在做教务系统课程设计,建议按“表设计 → 实体类 → JWT 和事务 → 并发验证”的顺序推进,别直接跳到写接口。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询