简介采用JavaSSMMavenMysqlBootstrap构建的学校教务管理系统面向高校教务管理部门、教师和学生解决课程信息维护、师生档案管理、教师评分、学生选课等日常教务管理难题覆盖从管理员后台到学生前台的完整业务链路适合正在学习SSM整合的JavaWeb开发者参考。项目采用Controller-Service-Mapper三层架构JSP担任视图层Spring管理业务组件MyBatis处理数据持久化Maven统一管理依赖管理员可对课程、学生、教师进行增删改查教师能给学生打分学生可自主选课。压缩包为zip格式共393个文件大小17.82MB包含55个Java源码、152个class编译文件、58个JSP页面、58个MyBatis XML映射及30个依赖JAR包另有SQL数据库脚本、properties配置文件和CSS/JS/字体图片等前端资源。目前已有1333人学习下载系统经多次测试运行无误项目基于Maven构建配置好本地环境即可运行。随包附有SQL建表脚本和docx说明文档可帮助理解SSM框架整合、Maven使用、教务业务分层实现等关键点适合课程设计、毕业设计或SSM入门实践。1. 学校教务管理系统烂大街的需求为什么还值得用 SSM 完整跑一遍学校教务管理系统大概是“管理系统”里被点单率最高的需求但真正动手做的人会发现一个用户要拆成管理员、教师、学生三种身份一门课要经历开课、选课、成绩、统计好几个阶段数据模型比单纯增删改查复杂一个量级。JavaSSMMavenMySQLBootstrap 这套组合在 Spring Boot 普及后看起来不够时髦但对中小型学校、培训机构或内部项目来说它仍然是配置透明、排错路径清晰、部署环境兼容性好的选择。这篇笔记按从选型、表设计、项目搭建到线上排错的顺序把这条链路完整讲一遍适合想看清 SSM 全细节的新手也适合要做短周期交付的开发者。2. 技术选型拆解SSMMySQLBootstrap 这套组合到底赢在哪2.1 Spring、SpringMVC、MyBatis的分工教务场景为什么不是全家桶更香SSM 三件套的职责边界其实很干净Spring 管对象生命周期和事务SpringMVC 管 HTTP 路由、参数绑定和视图解析MyBatis 管 SQL 映射和结果集到对象的转换。教务系统最典型的痛点是查询条件多例如按学期、课程名、教师、容量状态做组合筛选MyBatis 允许你直接写 SQL用动态标签控制条件拼接最终执行的 SQL 白纸黑字看得见。换成 JPA 这类自动生成 SQL 的方案复杂查询绕来绕去最后还得回头调 JPQL 或原生 SQL。经常有人问Spring Boot 已经把配置做成一站式了为什么还要拼 SSM我的经验是这类系统经常部署在单位内网或旧服务器上容器版本、JDK 版本都不是开发者能选的。SSM 的配置是显式的哪一段出问题看配置就能定位不至于一出问题就变成黑匣子。对学习型项目来说把 Bean 装配、事务代理、Mapper 扫描这些概念亲手配一遍收获比直接依赖自动配置大得多。MyBatis 的另一个价值是 SQL 可调优。教务系统学期末有成绩统计、报表导出这类重查询SQL 写在映射文件里接手的人可以直接看执行计划做优化。不过要提醒一句SSM 的配置分散如果没有人维护边界Controller 里写 SQL、Service 里写页面跳转的事会越来越多。所以我在项目里一般会规定调用方向Controller 只收参数和返回视图Service 只做业务规则DAO 只做数据读写Entity 对表结构。请求链路就是 JSP 发起请求经过 DispatcherServlet 进 Controller再往下走 Service、Mapper最后落到 MySQL返回时原路走一遍。这个习惯能让项目撑到两百个接口还保持可维护。2.2 Maven管依赖与模块Bootstrap管界面两件不太起眼的“基础设施”Maven 在这个系统里解决的事情很朴素下载并托管依赖统一 jar 包版本避免“在我本地能跑你那里不行”的经典场面。用 Maven 聚合工程可以把项目拆成公共模块、数据访问模块、Web 模块依赖边界通过模块依赖关系强制下来比口头约定管用。Bootstrap 则是管理端页面的主力教务系统的页面类型高度重复登录页、左侧菜单、数据表格、编辑表单、确认弹窗Bootstrap 的栅格、表格样式、表单校验、模态框基本全覆盖。有人会问为什么不用 Vue 或 React。对一个内部教务系统来说前端交互需求大多是“弹窗确认一下”“局部刷新一下”用服务端渲染 JSP 加少量 jQuery 和 Ajax开发周期短、维护门槛低、不需要额外构建链路。Bootstrap 在这里不是“退而求其次”而是刚好卡在需求复杂度最低的那一档。接下来才是整个系统里最难推倒重来的部分表结构设计。3. 把教务系统的表结构设计对身份、课程、选课成绩的一次建模3.1 用户与角色一个role字段够用什么时候才需要权限表教务系统的身份就三种管理员、教师、学生。我的习惯是用户表里直接放一个 role 字段而不是一上来就建 RBAC 五张表。三种角色用 TINYINT 足够权限粒度到“模块 / 操作”时用第 6 章要讲的拦截器按角色判断 URL 前缀就够了。上来就做完整权限模型开发成本翻倍维护成本也高最后大概率还是只用到三种角色。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password VARCHAR(64) NOT NULL, real_name VARCHAR(32) NOT NULL, role TINYINT NOT NULL DEFAULT 3 COMMENT 1-管理员 2-教师 3-学生, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个容易踩的点。第一密码列长度不要设成 32 就完事如果后面决定用 BCrypt 加密哈希值有 60 个字符VARCHAR(32) 会直接存不下。第二不要在用户表里堆字段教师需要归属院系、学生需要归属班级我一般会再建 t_teacher_ext、t_student_ext 这样的补充表用户表只保留登录凭证和全局角色。这样后续加职称、加学制都不需要动登录链路。3.2 课程、选课与成绩一个学期里的数据是怎么流转的课程表描述“这学期开了什么课”选课表描述“谁选了这门课”成绩本质上依附于选课记录存在。我见过的简洁做法是把选课和成绩放进同一张表选课记录创建时 score 为空录入成绩时再更新这一行。这样查“某学生的成绩单”就是一次关联查询不需要再 join 一张单独的成绩表。CREATE TABLE t_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL, course_name VARCHAR(64) NOT NULL, credit DECIMAL(3,1) NOT NULL, capacity INT NOT NULL DEFAULT 0, teacher_id BIGINT NOT NULL, semester VARCHAR(16) NOT NULL, UNIQUE KEY uk_course_no (course_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, score DECIMAL(5,2) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_course_student (course_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;选课表里的唯一索引 uk_course_student 是关键它从数据库层面保证同一个学生不能重复选同一门课。也就是说即使代码里漏了判断数据库也会拦住重复插入。容量控制在表结构层面做不了必须靠 Service 层在事务里先锁行再判断具体写法放在第 4 章的防超选示例里。这里有一个取舍要讲清楚把成绩放进选课表适合“一次选课对应一次成绩”的简单场景。如果学校有补考、重修、成绩变更记录单独建 t_score 表更合适因为一次选课会产生多个成绩版本。判断标准很简单——成绩和选课是否一对一是就合表否则拆表。关键字段所在表作用rolet_user区分管理员 / 教师 / 学生capacityt_course开课时设定的选课名额上限uk_course_studentt_course_selection唯一索引防重复选课scoret_course_selection与学生一次选课记录绑定4. 从 pom 到 Mapper用 MavenSSM 搭起能跑的最小工程4.1 用Maven聚合工程搭骨架parent pom与三个子模块教务系统不一定要拆模块但我习惯至少拆成 cms-common、cms-dao、cms-web 三个。common 放通用工具和统一返回结构dao 放实体类和 Mapperweb 放 Controller、Service 和页面。依赖方向强制为 web 依赖 daodao 依赖 common这样分层的边界是代码结构约束出来的不是靠自觉。groupIdcom.demo/groupId artifactIdcms-parent/artifactId version1.0.0/version packagingpom/packaging modules modulecms-common/module modulecms-dao/module modulecms-web/module /modules properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties父 pom 的 packaging 必须是 pom它只做两件事聚合子模块、统一版本。具体依赖不要堆在父 pom 里而是放在 dependencyManagement 中统一管理版本号子模块按需引入这样不会出现 cms-dao 里用 MyBatis 3.x、cms-web 里却引了 2.x 的冲突。子模块里写依赖时不写 version统一从父 pom 继承。dependencyManagement dependencies dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency /dependencies /dependencyManagement版本号只是一种选择实际项目中根据自己的环境锁定即可。核心是让所有子模块在同一个版本体系里避免“jar 包地狱”。cms-web 最终打包成 war部署到外部 Tomcat 的 webapps 目录下cms-common 和 cms-dao 打成普通 jar 被上层模块引用。4.2 Spring与SpringMVC配置数据源、SqlSessionFactory、视图解析器SSM 最让人头晕的是配置文件多但每一个都有明确职责。web.xml 里要干三件事加载 Spring 根容器、加载 SpringMVC 子容器、配置编码过滤器。根容器用 ContextLoaderListener 读取负责数据源、事务、ServiceSpringMVC 子容器只扫 Controller负责路由和视图。context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mappingDispatcherServlet 的 url-pattern 配成/意味着所有请求都先进 SpringMVC包括 css、js 这些静态资源所以第 5 章会专门讲静态资源被拦截的问题。CharacterEncodingFilter 的 forceEncoding 要设成 true否则只有请求编码被设置响应编码不会变仍然可能乱码。spring.xml 里最关键的是数据源、SqlSessionFactory 和事务管理器。MySQL 8.x 的驱动类名变成了 com.mysql.cj.jdbc.Driver连接 URL 里 serverTimezone 参数也必须带上否则会报时区错误。context:component-scan base-packagecom.demo.cms context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/cms?useUnicodetrueamp;characterEncodingutf8amp;useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword valueroot/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.demo.cms.dao/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/这里要注意 component-scan 排除 Controller 注解因为 Controller 应该由 spring-mvc.xml 那个子容器扫描。如果两个容器都扫到了同一个 Controller会出现代理混乱事务偶尔生效偶尔不生效。MapperScannerConfigurer 的 basePackage 是 Mapper 接口所在的包它会把包下所有接口都扫描并生成代理对象。schemaLocation 里必须包含 tx 的命名空间定义否则tx:annotation-driven会直接报配置解析错误。spring-mvc.xml 就轻量很多只管 Controller 扫描、视图解析器和静态资源放行context:component-scan base-packagecom.demo.cms use-default-filtersfalse context:include-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean mvc:annotation-driven/ mvc:resources mapping/static/** location/static//视图解析器的 prefix 指向 WEB-INF/views这样 JSP 放在 WEB-INF 下面用户没办法通过 URL 直接访问到页面文件只能通过 Controller 跳转。mvc:resources 把 /static/** 的请求放行不经过 Controller否则浏览器加载 CSS、JS 全部 404。4.3 Mapper接口与XML一段能跑的查询和一次防超选的事务Mapper 是 SSM 里最需要“对暗号”的部分接口方法名、XML 里的 id、namespace 三者必须严格一致。我拿课程分页查询做一个能直接跑的示例。public interface CourseMapper { ListCourse selectCoursePage(Param(semester) String semester, Param(courseName) String courseName, Param(offset) int offset, Param(limit) int limit); Course lockCourseById(Param(courseId) Long courseId); int increaseSelectedCount(Param(courseId) Long courseId); }mapper namespacecom.demo.cms.dao.CourseMapper select idselectCoursePage resultTypecom.demo.cms.entity.Course SELECT id, course_no, course_name, credit, capacity, selected_count, semester FROM t_course where if testsemester ! null and semester ! AND semester #{semester} /if if testcourseName ! null and courseName ! AND course_name LIKE CONCAT(%, #{courseName}, %) /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /select select idlockCourseById resultTypecom.demo.cms.entity.Course SELECT id, course_no, course_name, credit, capacity, selected_count FROM t_course WHERE id #{courseId} FOR UPDATE /select update idincreaseSelectedCount UPDATE t_course SET selected_count selected_count 1 WHERE id #{courseId} /update /mapperwhere标签会自动去掉第一个条件前面多余的 AND比手工拼 WHERE 11 干净。#{semester}是预编译占位符传值仅作为参数绑定不会拼进 SQL这是防注入的基本盘。LIKE 查询里我用 CONCAT 拼模糊匹配不要用${courseName}直接拼否则用户输入一个单引号就能让 SQL 语法错误。选中 lockCourseById 里的FOR UPDATE这是防超选的关键。选课场景在事务里先锁住课程这一行再判断 selected_count 是否小于 capacity最后执行增加计数和插入选课记录三个操作在同一事务里提交或回滚。不加这把锁两个学生同时选最后一门课时可能都读到容量未满最后超员。Service public class CourseService { Autowired private CourseMapper courseMapper; Autowired private CourseSelectionMapper courseSelectionMapper; Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long courseId) { Course course courseMapper.lockCourseById(courseId); if (course null) { throw new BizException(课程不存在); } if (course.getSelectedCount() course.getCapacity()) { throw new BizException(该课程已选满); } courseMapper.increaseSelectedCount(courseId); courseSelectionMapper.insert(studentId, courseId); } }rollbackFor 设成 Exception.class 值得记住。默认情况下 Spring 只对 RuntimeException 回滚如果你在方法里 catch 了异常再抛一个受检异常事务不会回滚数据就悄悄写进去了。selectCourse 用事务同时包住“锁行判断”和“写入选课记录”两者要么都成功要么都失败。5. SSM 教务系统 5 个高频翻车点与排查方案5.1 配置类问题Invalid bound statement 与静态资源 404项目刚启动一调用 Mapper 就报Invalid bound statement (not found)这是 SSM 里最常见的翻车现场。现象是接口存在、方法也存在但运行时就是找不到 SQL。原因基本是三选一Mapper 接口的包名与 XML 的 namespace 不一致XML 文件没被 mapperLocations 扫描到或者接口不在 MapperScannerConfigurer 的 basePackage 里。排查顺序我一般是固定三步先开 XML 看 namespace 是不是接口的全限定名再确认 mapper 目录在 resources 下且路径与 mapperLocations 匹配最后看接口所在包与 MapperScannerConfigurer 的 basePackage 是否一致。还要注意一个细节接口名和 XML 文件名必须同名例如 CourseMapper.java 对应 CourseMapper.xml否则 MyBatis 按接口全限定名找 XML 时找不到。提示如果改了 XML 里的 namespace 还报错先 clean 一下项目再重启。target 目录里残留的旧 XML 经常让人以为是配置问题。第二个高频问题是页面能打开但完全没有样式。现象是浏览器控制台里 css、js 全部 404页面变成纯文本。原因很明确DispatcherServlet 映射到/静态资源请求也进了 SpringMVC 的流程找不到对应的 Controller 就返回 404。解决方法是第 4 章里那行mvc:resources mapping/static/** location/static//同时页面里的引用路径要用${pageContext.request.contextPath}拼接否则部署到带项目名的路径下时绝对路径全部失效。5.2 数据类问题事务不生效与中文乱码事务不生效是选课模块最容易踩的坑。现象很直接并发测试时选课人数超过 capacity库里出现脏数据。先检查 spring.xml 里有没有配置 DataSourceTransactionManager 和tx:annotation-driven缺一不可再检查 Service 方法上有没有 Transactional。还有一个常见原因是同类内部调用例如 CourseService 里一个方法调用同类的另一个 Transactional 方法Spring 的代理不会生效因为 this.selectCourse() 调用绕过代理对象直接走了原始对象。中文乱码是另一个老问题但现象容易被误判。如果数据库里存进去的是???不是页面显示问题是连接层编码出了问题。MySQL 连接 URL 必须带characterEncodingutf8并且建表时指定 CHARSETutf8mb4很多表建在默认 latin1 库下代码怎么配都没用。如果页面上输入中文提交后乱码但库里数据是正常的那问题在 Tomcat 连接器修改 server.xml 里 Connector 加 URIEncodingUTF-8同时保证 web.xml 的 CharacterEncodingFilter 存在。5.3 传参类问题日期 400 与 JSON 序列化异常日期参数导致 400 错误在教务系统里特别常见因为选课、成绩录入都要用到日期。现象是表单里选了一个日期提交后 SpringMVC 直接返回 400日志里提示字符串转 Date 失败。原因是 Spring 默认不知道你把日期字符串按什么格式解析常见解决是字段上加DateTimeFormat(pattern yyyy-MM-dd)。如果 Controller 形参是单个参数则配合RequestParam一起用。另一个容易翻车的是 Controller 返回 JSON 对象时出现循环引用或懒加载异常。现象是页面用 Ajax 请求课程详情接口直接报栈溢出或 session 关闭异常。原因通常是实体类里有相互引用的关联对象Jackson 序列化时来回循环或者是 MyBatis 延迟加载的对象在事务结束后再被序列化报 LazyInitializationException。解决方法是不要直接把实体类返回给前端组装一个只包含展示字段的 DTO或者在不输出的字段上加忽略注解。6. 把登录和权限收敛进拦截器一个值得长期保留的横切设计6.1 为什么把登录校验放到拦截器而不是 Controller很多人在做教务系统时把登录判断写在每个 Controller 方法里比如先 getSession 再判断 user 是否为空。这个做法前期看着没毛病但接口一旦多起来代码到处重复漏掉一个没判断的接口就是权限漏洞。我一般的做法是用 HandlerInterceptor 把登录态和角色控制统一收口让 Controller 只关注业务参数和页面跳转。public class AuthInterceptor implements HandlerInterceptor { private static final String LOGIN_URI /login; private static final String ADMIN_PREFIX /admin; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() LOGIN_URI); return false; } String uri request.getRequestURI(); boolean isAdminRequest uri.startsWith(request.getContextPath() ADMIN_PREFIX); if (isAdminRequest !1.equals(String.valueOf(user.getRole()))) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }这段代码把两件事放在一起处理拦截未登录用户跳转登录页拦截非管理员访问 /admin 前缀的页面。注意 role 字段从数据库取出来是 TINYINT在 Java 里可能是 Byte 类型直接用 equals 对比字符串容易踩坑先转 String 再比较。用 URL 前缀控制权限比在每个方法上加自定义注解更直接注解方式容易漏标漏标就等于放行。6.2 注册方式与两个必须放行的路径拦截器写好后要在 spring-mvc.xml 里注册并且把登录接口和静态资源排除掉否则会形成死循环登录页的 CSS 加载请求被拦截又跳回登录页浏览器报重定向次数过多。mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/static/**/ bean classcom.demo.cms.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-mapping 里的 /login 要让登录页本身和登录表单提交接口都能通过/static/** 放行所有静态资源。如果后续有不需要登录就能访问的公共页面比如学校官网的公告列表就继续往这里加排除项而不是在拦截器里写一长串 if 判断。我第一次做教务系统时把权限判断写在 Controller 里后来每个接口都要复制粘贴一大段 session 判断代码改权限规则时改到怀疑人生。后来才明白这种横切逻辑本来就该放进拦截器让业务方法保持干净。建议你在搭登录模块时就顺手把这层做好别等接口多了再回头重构那才是真的后悔药都找不到。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?