
简介基于 Java 与 SSM 架构开发的教务查询系统面向刚接触 SSM 整合的 Java 后端初学者定位为可运行的练手项目。代码覆盖 Spring、SpringMVC、MyBatis 三大框架的完整整合并引入 Shiro 安全框架、C3P0 数据源、Log4j 日志和 Bootstrap 前端能够演示登录、课程、选课等典型教务查询场景。资源包共 271 个文件压缩后约 35.32MBJava 源码与 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 17Spring 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和一个连接池实现比如 HikariCPSpring 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 ResultVoid 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); SetString 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 行切分多线程并行写库最后等所有批次都落完了再统一提交。ListListScoreRow batchList partition(rowList, 200); CountDownLatch latch new CountDownLatch(batchList.size()); ExecutorService pool Executors.newFixedThreadPool(4); for (ListScoreRow 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工具打一个只读的课程列表接口确认连接池参数调优后的吞吐变化再考虑对写接口做有限度的并发测试。选课系统的崩溃路径大多是数据库连接耗尽优先观察活跃连接数和慢查询日志比堆更多中间件有效。本文还有配套的精品资源点击获取