简介:基于 Java 与 SSM 架构开发的教务查询系统,面向刚接触 SSM 整合的 Java 后端初学者,定位为可运行的练手项目。代码覆盖 Spring、SpringMVC、MyBatis 三大框架的完整整合,并引入 Shiro 安全框架、C3P0 数据源、Log4j 日志和 Bootstrap 前端,能够演示登录、课程、选课等典型教务查询场景。资源包共 271 个文件,压缩后约 35.32MB;Java 源码与 class 文件约占总文件近半,XML 配置和 JSP 页面分离规整,另有 SQL 脚本、JAR 依赖、IDE 配置及少量说明文档,便于导入开发工具后直接编译阅读与调试。已有 411 人学习/浏览,适合用来拆解 SSM 分层结构、理解 Dao/Service/Controller 调用链,并参考 Shiro 权限控制与前端交互的落地方式。配合数据库初始化脚本和项目配置文件,读者可快速跑通登录、课程、选课等核心流程,是巩固 Java Web 后端技能的实用素材。
1. 基于 Java 开发的教务系统,难点不在 CRUD 而在状态流转
教务系统最容易写崩的地方不是增删改查,而是课程从“发布”到“选满”再到“结课出分”这条状态链。学生端一个刷新,连同教师端的排课和成绩维护一起构成跨服务调用,选课季五分钟内的请求量能顶平时一天。基于 Java 开发的教务系统之所以是主流,是因为它能在 Spring Boot 里用一套事务和 AOP 把这些跨模块的规则固化成拦截逻辑,让学号、教师号、课程号形成明确的边界,而不是散在 JS 和 SQL 里到处飞。
这套系统通常由学生端选课、教师端成绩录入、教务端排课维护三块组成,技术路线基本收敛为 Spring Boot 负责接口和服务层,MyBatis 处理持久化,MySQL 承载业务数据,Redis 处理选课高峰期的限流和热点缓存。适合的人群包括:接学校管理系统项目的外包开发者、刚转后端想找个完整业务练手的 Java 初学者,以及要系统梳理 Spring 事务、动态代理和权限模型的在职工程师。下面从数据库设计开始,一层层把教务系统拆开。
2. 教务系统领域建模与数据库表怎么定
2.1 先画出实体关系,教务和学工的数据别混在一起
开始写代码前,先把业务对象固定下来。教务系统的核心实体是学生、教师、课程、选课记录、成绩,再辅助一个贯穿所有查询的学年学期字段。学生和教师信息虽然来自学工或人事系统,但在教务库里至少要冗余一份,因为选课和成绩查询都要高频关联学生基本信息,每次都跨系统调用会把简单接口拖慢两个数量级。
实体之间的关联关系可以描述为:一个学生和一门课程是多对多,中间关系的载体就是选课记录;一个教师和一门课程是一对多,一门课只有一个主讲教师;选课记录和成绩是一对一,成绩针对的是某个学生对某门课的选课行为,不针对学生也不针对课程本身。这个关系定下来之后,数据库表的设计就顺了。
需要特别注意的边界是教务系统和教学评价系统、学工系统的边界。课程容量、选课状态、成绩由教务管,考勤、评价指标、奖学金资格不属于本系统核心,最多通过学生 ID 关联。实际开发时常见错误是看到需求清单里带“绩点计算”就顺手把 GPA 全量算了一遍放进主表,其实绩点属于统计结果,应该在查询时按规则算出来,不落库反而省掉大量一致性问题。
2.2 MySQL 建表,把约束和索引一次建对
2.2.1 学生表和教师表,唯一键用业务编号而不是主键
下面给出一个可直接执行的建表 SQL,学生和教师共用一套字段风格。
CREATE TABLE t_student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL COMMENT '学号', name VARCHAR(32) NOT NULL, enroll_year SMALLINT NOT NULL COMMENT '入学年份', college_name VARCHAR(64) NOT NULL COMMENT '所属学院', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在读 0离校', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_no (student_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表'; CREATE TABLE t_teacher ( id BIGINT AUTO_INCREMENT PRIMARY KEY, teacher_no VARCHAR(20) NOT NULL COMMENT '工号', name VARCHAR(32) NOT NULL, title VARCHAR(32) COMMENT '职称:讲师/副教授/教授', college_name VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_teacher_no (teacher_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师表';这里有个容易犯的错:把student_no当成id用。学号只是业务意义上的唯一标识,一旦遇到转学、专升本换号或者历史数据清洗就得改主键,牵连所有外键表。主键id用自增整数,业务编号加UNIQUE KEY,两边都不耽误。enroll_year用SMALLINT就够,不需要存完整日期。
2.2.2 课程表与选课表,容量用整型字段维护
课程表要包含开课信息、容量和当前选中人数。选课表则用联合唯一索引挡住重复选课。
CREATE TABLE t_course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_no VARCHAR(20) NOT NULL COMMENT '课程编号', course_name VARCHAR(64) NOT NULL, teacher_id BIGINT NOT NULL, credit DECIMAL(3, 1) NOT NULL, capacity INT NOT NULL DEFAULT 60 COMMENT '课程容量', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数', is_published TINYINT NOT NULL DEFAULT 0 COMMENT '0未开放 1已开放', academic_year VARCHAR(9) NOT NULL COMMENT '学年 2025-2026', semester TINYINT NOT NULL COMMENT '1第一学期 2第二学期', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_teacher_id (teacher_id), KEY idx_year_semester (academic_year, semester) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; CREATE TABLE t_selection ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1已选 0退选', select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course_id (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录表';成绩表单独建,理由是选课记录在选课阶段高频写入,成绩在期末才出现,两个生命周期的访问模式不一样。成绩表以student_id加course_id做联合唯一键,和选课记录保持一对一。
CREATE TABLE t_score ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, score DECIMAL(5, 2) COMMENT '百分制,未录入时为NULL', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已归档', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩表';2.3 selected_count 字段的意义与一致性取舍
selected_count不是冗余,它是选课流程里的并发闸门。选课业务的核心约束是“人数不能超过容量”,如果用SELECT COUNT(*) FROM t_selection WHERE course_id = ?来校验,并发时两个请求同时读到同一个未满数字,就会都通过校验,最终超过容量。
种场景下,靠UPDATE t_course SET selected_count = selected_count + 1 WHERE course_id = ? AND selected_count < capacity这类条件更新能把判断和扣减合并为一条原子 SQL。把关键状态放在一行数据里,用数据库行锁保证准确性,这就是selected_count存在的意义。至于是否要额外建选课中间表快照,多数小型系统没必要,直接查关联表即可。
提示:所有业务表统一用
utf8mb4和InnoDB,避免课程名或学生姓名出现生僻字时写入乱码。外键约束在实际项目里我一般不加,改成在 Service 层显式校验,否则大表 DDL 和迁移时会被外键拖累。
3. 用 Spring Boot 把选课与成绩流程跑通
3.1 工程结构与运行环境准备
基于 Java 11 或 17 都可以,建议优先 JDK 17,Spring Boot 2.7 之后对它的支持最成熟。环境上先装 JDK 并配好JAVA_HOME,之后在 IDEA 里导入 Maven 工程。包结构按职责划分:
edu-system/ ├─ controller/ # HTTP 接口层 ├─ service/ # 业务逻辑与事务边界 ├─ mapper/ # MyBatis 数据访问接口 ├─ entity/ # 数据库实体类 └─ config/ # 拦截器、权限、全局异常配置依赖就引入spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j和一个连接池实现,比如 HikariCP,Spring Boot 默认自带。Service 层接口和实现类是否要拆分看团队习惯,拆分的好处是事务代理切得干净,坏处是类数量翻倍。单体教务系统不必强行接口化,我一般只写一个实现类,用 Spring 的@Service直接注册。
3.2 选课接口:锁、扣减与事务边界
选课是教务系统里最容易出并发问题的地方,下面是一个可直接改用的 Service 实现。
@Service public class CourseSelectionService { private final CourseMapper courseMapper; private final SelectionMapper selectionMapper; @Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 校验课程存在且已开放 Course course = courseMapper.selectById(courseId); if (course == null || course.getIsPublished() != 1) { throw new BizException("课程不存在或未开放选课"); } // 2. 防止重复选课,数据库唯一索引兜底 int count = selectionMapper.countByStudentAndCourse(studentId, courseId); if (count > 0) { throw new BizException("请勿重复选课"); } // 3. 原子扣减容量,这是避免超选的唯一可靠闸门 int rows = courseMapper.decreaseStock(courseId); if (rows == 0) { throw new BizException("课程余量为0,选课失败"); } // 4. 写入选课记录 Selection selection = new Selection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setStatus(1); selectionMapper.insert(selection); } }对应的 Mapper 关键方法如下:
@Mapper public interface CourseMapper { @Update("UPDATE t_course SET selected_count = selected_count + 1 " + "WHERE id = #{courseId} AND selected_count < capacity") int decreaseStock(@Param("courseId") Long courseId); }三个点值得展开。第一,扣减和判断在一条UPDATE里完成,数据库行锁保证不会出现两个事务同时把selected_count加到超容量。第二,@Transactional保证扣减和插入选课记录是同生共死,任何一步抛异常,事务回滚都会把selected_count还原。第三,insert时如果遇到唯一键冲突说明重复选课,通过全局异常处理器捕获并转成友好提示,而不是把堆栈抛给前端。
事务边界要精确,不要在事务里调用远程接口或做耗时的 Excel 解析。比如成绩导入场景,文件解析放在事务外,事务内只做批量UPDATE。
3.3 成绩录入、修改与发布的状态控制
成绩模块最容易犯的错误是不做状态管理,老师录了多少学生立刻能看到。合理的流程是:教师录入草稿,教务审核,发布后学生端才可见。
@Transactional(rollbackFor = Exception.class) public void saveScore(Long teacherId, ScoreDTO dto) { // 1. 校验授课归属,教师只能录自己课程的分数 int ownerCount = courseMapper.countByTeacherAndCourse(teacherId, dto.getCourseId()); if (ownerCount == 0) { throw new BizException("只能录自己授课课程的成绩"); } // 2. 分数范围校验 if (dto.getScore() != null && (dto.getScore() < 0 || dto.getScore() > 100)) { throw new BizException("成绩必须在0到100之间"); } // 3. 状态为草稿时只做UPDATE,不触发学生端查询 scoreMapper.upsertScore(dto.getCourseId(), dto.getStudentId(), dto.getScore(), 0); } public void publishScore(Long courseId) { scoreMapper.updateStatus(courseId, 1); }这里把查询和写入分离,成绩发布本质上是一次UPDATE ... SET status = 1,不需要把几百个学生成绩逐条修数组。学生端查询接口只认status = 1的记录,草稿就查不到。这种设计思路和 Java 面试里常问的“乐观锁版本号更新”一致,都是靠状态字段而非业务判断来保证一致性。
成绩修改要留痕迹。正式系统里会加一张t_score_log记录修改前值、修改后值、操作人、操作时间。学生端查到的是最新值,教务端可以追溯历史,一张日志表解决纠纷同时不干扰主表读写。
4. 教务系统的 RBAC 权限设计与接口防越权
4.1 三类角色与权限矩阵划分
教务系统的用户角色可以收敛为三类:教务管理员具有所有管理权限,教师拥有与自己课程相关的录入与查询权限,学生仅拥有与本人相关的操作权限。
| 操作 | 学生 | 教师 | 教务管理员 |
|---|---|---|---|
| 选课 / 退课 | 有 | 无 | 无 |
| 录入期末成绩 | 无 | 有(限本人课程) | 有(全课程) |
| 发布成绩 | 无 | 无 | 有 |
| 排课 / 调课 | 无 | 无 | 有 |
| 查询学生名册 | 无 | 有(限本人课程) | 有 |
看起来权限矩阵很小,实际项目里容易出问题的是“越权”,即教师 A 通过拼接 URL 把成绩录到了教师 B 的课程下。解决办法是每个教师接口都做归属校验,把teacher_id从请求参数里剥离,改从登录态中解析。
4.2 用注解加 AOP 拦截接口权限
常用做法是自定义一个@RequiresRole注解,配合 Spring MVC 拦截器或 Spring AOP 做切面拦截。Spring 处理@Transactional和切面依赖动态代理机制,这也是 Java 面试题里常考的动态代理在真实项目里的落地方案。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresRole { String[] value(); }在 Controller 方法上标注:
@RestController @RequestMapping("/api/course") public class CourseController { @RequiresRole({"STUDENT"}) @PostMapping("/select") public Result<Void> select(@RequestBody SelectRequest req) { courseSelectionService.selectCourse(req.getStudentId(), req.getCourseId()); return Result.ok(); } }权限校验放在拦截器里:
@Component public class RoleInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } RequiresRole required = handlerMethod.getMethodAnnotation(RequiresRole.class); if (required == null) { return true; } Long userId = (Long) request.getAttribute("userId"); Set<String> roles = getUserRoles(userId); for (String role : required.value()) { if (roles.contains(role)) { return true; } } throw new ForbiddenException("无权限访问该接口"); } }这里的关键是userId从哪来。登录成功后把用户 ID 放入请求 Attribute 或写成独立的UserContext工具类,避免每次在业务代码里从 Token 解析一遍。角色列表建议直接查 Redis,而不是每次请求都打数据库,教师和学生表都不过几十万行,但整校师生触发频率高的接口会把这个查询放大很多倍。
数据权限比角色判断更隐蔽。教师能看到的课程列表是“自己教的”,学生能看到的成绩是“自己的”,所以查询接口的 SQL 里必须带teacher_id = ?或student_id = ?,不能只靠前端传参数。这点排错时最痛苦,权限看着正常,数据却乱了,八成就是这里没过滤。
4.3 Token 过期与单点登录的取舍
管理系统常用的方案是登录后签发 JWT,设置 30 分钟过期,Redis 里存一份 sessionId 做续期和主动踢人。很多系统只用了 JWT 的校验逻辑,却忽略注销问题,前端清了 Token,后端还没失效,形成安全隐患。
推荐的做法是双端校验:JWT 里放userId和role,每次请求再查一下 Redis 里是否存在该会话的 Key,不存在则判定未登录。这个方案比纯无状态 JWT 多一次 Redis 查询,但换来的是“教务还能把某个教师踢下线”的运营能力,权限收回能立即生效。教务系统这类 B 端管理后台,运营可控比性能更重要。
5. 选课高峰的并发优化与 Java 排错实用技巧
5.1 先调连接池和 MySQL 慢查询
选课高峰一来,最先打爆的往往是数据库连接池。Spring Boot 默认的 HikariCP 配置在application.yml里给出明确值:
spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 5000 max-lifetime: 1800000maximum-pool-size不是越大越好,连接数过多时 MySQL 自身会达到连接上限,而且每次切换连接都有开销。同时打开 MySQL 慢查询日志,把超过 500ms 的 SQL 捞出来:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 0.5;选课高峰如果只是正常业务流量而不是攻击流量,通过索引优化基本能扛住。建立联合索引的顺序通常把区分度高的字段放前面,比如(course_id, status)比(status, course_id)更适合查某门课当前有效选课数。
5.2 Redis 分布式锁与限流,别做重复轮子
很多项目在选课接口上盲目引入 Redis 分布式锁,实际上前面用条件更新已经保证容量正确性了,分布式锁只解决“同一作业并发提交”的服务级幂等问题。如果一定要用,推荐SET NX EX方式:
Boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock:select:" + studentId, "1", Duration.ofSeconds(2)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("操作太频繁,请稍后重试"); }锁的粒度按学生而不是按课程,同一学生防重复提交,不同学生之间的选课互不阻塞。锁过期时间一般设置 1 到 3 秒,超过这个时间业务还没写完说明事务内有慢查询,锁续期是复杂的反模式。
5.3 成绩批量导入用 CountDownLatch 编排等待
成绩录入阶段大家往往会用 Excel 导入几百上千条记录,一条条循环插入既慢又占连接。常见做是把 Excel 解析出的数据按每批 200 行切分,多线程并行写库,最后等所有批次都落完了再统一提交。
List<List<ScoreRow>> batchList = partition(rowList, 200); CountDownLatch latch = new CountDownLatch(batchList.size()); ExecutorService pool = Executors.newFixedThreadPool(4); for (List<ScoreRow> batch : batchList) { pool.submit(() -> { try { scoreService.batchInsert(batch); } catch (Exception e) { log.error("成绩批次写入失败", e); } finally { latch.countDown(); } }); } latch.await(30, TimeUnit.SECONDS);线程数量按连接池大小的一半来配,避免把连接抢光。latch.await带超时时间,主线程最多等 30 秒,返回后检查批次的失败次数,有失败把错误行号汇总给前端,这样业务线程不会无限挂起。
5.4 验证并发正确性的最小压测方法
不用急着上 JMeter 压测,先用两个终端模拟并发就能暴露大部分逻辑问题。第一条命令快速循环提交选课,第二条同时提交同一学生同一课程的请求,观察是否出现重复选课或超过容量的记录。更接近真实的服务端压测,可用ab工具打一个只读的课程列表接口,确认连接池参数调优后的吞吐变化,再考虑对写接口做有限度的并发测试。选课系统的崩溃路径大多是数据库连接耗尽,优先观察活跃连接数和慢查询日志比堆更多中间件有效。
本文还有配套的精品资源,点击获取