简介基于JavaSSMMavenMySQLBootstrap构建的学校教务管理系统面向Java Web初学者与毕业设计场景围绕管理员对课程、学生、教师的增删改查、教师给学生打分、学生自主选课等核心功能展开帮助掌握SSM整合开发与Maven工程构建。压缩包17.82MB共393个文件其中以55个Java源码、58个JSP视图、58个XML配置、30个JAR依赖和SQL数据库脚本为主体附有CSS/JS样式文件与说明文档导入Maven工程即可运行。项目通过MyBatis逆向工程自动生成Example条件查询类配合数据库建表脚本与分层包结构可直接用于学习SSM底层原理、前端表单交互和通用增删改查模板且系统经过多轮测试运行稳定。目前已有1333人学习使用适合作为课程设计、实训项目或二次开发的基础源码。1. SSM 教务管理系统这个资源骨架完整适合动手复现拿这套 JavaSSMMavenMySQLBootstrap 写的学校教务管理系统去实际部署最先打脸的往往不是业务逻辑而是环境。有人把源码包导进去后连 Tomcat 都起不来反复报 ClassNotFound最后才发现是 Maven 本地仓库和 JDK 版本没对齐。这个资源覆盖三类角色管理员能对课程、学生、教师做增删改查教师能给学生打分学生能完成选课。它不算花哨却把 SSM 的核心链路完整走了一遍Controller 转发、Service 事务、Mapper 写 SQL、JSP 渲染页面。适合正在学 SSM、想拿完整案例练手的人也适合改造成课设或毕设骨架的同学。下文从选型开始把环境先配通再拆数据库表与三个核心流程最后把最常见的坑逐个排掉。2. 选型与环境先把 Maven 拧紧项目才跑得起来2.1 五个组件各自管什么为什么这么配SSM 不是三个框架的简单叠加而是一套约定成熟的协作链。Spring 负责对象创建依赖注入也管事务边界SpringMVC 负责前端请求到达 Controller、参数绑定、结果转发到 JSPMyBatis 把 Java 接口和 SQL 映射起来替代手工写 JDBC 的样板代码。Maven 管的是依赖版本和打包MySQL 管持久化Bootstrap 则让后台页面不至于裸奔。很多同学一开始把注意力全放在增删改查上结果卡在依赖冲突上所以顺序上我会先讲环境。组件常见版本在工程中的角色最容易出问题的地方JDK1.8编译与运行环境IDE 编译级别和 Maven 的 source/target 不一致Maven3.6.x依赖管理与 war 打包本地仓库路径、镜像源、lastUpdated 缓存MySQL5.7 / 8.0数据持久化驱动类名、时区参数、建表字符集Spring5.2.xIoC 容器与事务传递依赖版本冲突MyBatis3.5.xSQL 映射Mapper XML 是否进入 classpathBootstrap3.x / 4.x后台 UI静态资源路径和项目 context path这套组合为什么到现在还有参考价值Spring Boot 把什么都自动配好了反而让一批人说不清 Controller、Service、Mapper 是怎么串起来的。SSM 的优势是配置暴露在明处你能亲眼看到数据源怎么注入、事务切到哪个 Service 方法、一个请求从 DispatcherServlet 到 JSP 的完整路径。对新手来说这种“看得见接线”的项目反而比一键生成的工程更能建立系统观。2.2 把 pom.xml 和 jdbc.properties 先配好拿到源码包我一般不会急着点运行按钮而是把依赖文件先过一遍。pom.xml 里最重要的不是依赖多而是版本是否互相兼容。SSM 的老资源常常用 Spring 4.x如果你现在用的是 JDK 17那基本必翻车。我的建议是保持 JDK 8 Spring 5.2.x MyBatis 3.5.x这是这套架构最稳的区间。properties project.build.sourceEncodingUTF-8/project.build.sourceEncoding maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.2.22.RELEASE/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies这里的前四行决定了编译基线。maven.compiler.source和 target 都写 1.8是为了防止 IDEA 默认用当前 JDK 版本去编译老代码编译时出现source option 7 is no longer supported之类的问题。spring-webmvc 会连带引入 spring-context、spring-web、spring-beans所以不需要单独逐个添加。mybatis-spring 是把 SqlSessionFactory 交给 Spring 管理的桥梁没有它Mapper 接口扫描就无从谈起。mysql-connector-java 的版本要看你的数据库若是 MySQL 5.7 用 5.1.49 也能跑但配 MySQL 8.0 就必须用带cj的驱动类。数据源配置集中在 jdbc.properties 里编码和时区是我必查的两行。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/edu_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456useUnicodetruecharacterEncodingutf8解决中文写入乱码serverTimezoneAsia/Shanghai解决 MySQL 8 的时间偏移报错。老资源里写的是com.mysql.jdbc.Driver那是 5.x 驱动的写法换成 8.x 驱动后不改正会导致启动时驱动类加载失败。密码不要用 123456 这种明摆着给别人看的本地测试无所谓交付前必须改成环境变量或加密配置。2.3 Maven 构建命令与配置顺序配置完依赖接下来是所有资源落地最容易被忽略的一步本地仓库。先用mvn -v确认 Maven 版本和仓库位置再执行构建。mvn -v mvn clean install -DskipTests mvn dependency:tree -Dincludesorg.springframeworkmvn clean install -DskipTests会先清掉 target再把项目打成本地包跳过测试能省下编译测试类的时间。dependency:tree -Dincludesorg.springframework是我用来查版本冲突的常用命令它会把 spring 相关的依赖树全部列出来。如果看到一个工程里同时出现 spring 4.3 和 spring 5.2那基本是某个第三方依赖传递进来的必须用exclusions排除否则启动时会出现各种NoSuchMethodError这类问题最玄学看报错完全看不出是版本冲突。还有一点常见的坑IDEA 里 File → Project Structure → Project SDK 要选 1.8Modules 的 Language level 也要对应Maven 的 Runner 里 JRE 选 1.8。三个地方设置不一致Maven 编译一个版本、IDEA 编译一个版本运行时就等着看UnsupportedClassVersionError。这不算系统的 bug但十个跑不起来的工程里有五个是栽在这种地方。3. 数据库设计与工程结构四张表把教务系统立住3.1 表结构学生、教师、课程、选课关系教务系统的数据模型并不复杂核心只有四个实体学生、教师、课程、选课记录。难点在关系学生和课程是多对多需要中间表教师和课程是一对多课程表里挂一个 teacher_id 外键。很多翻车事故都出在删除动作上所以建表时我会把外键和唯一索引一并写清楚。CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL DEFAULT 123456, gender TINYINT DEFAULT 0 COMMENT 0男 1女, class_name VARCHAR(50) COMMENT 班级, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE teacher ( id INT PRIMARY KEY AUTO_INCREMENT, teacher_no VARCHAR(20) NOT NULL UNIQUE COMMENT 工号, name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL DEFAULT 123456, title VARCHAR(20) COMMENT 职称, department VARCHAR(50) COMMENT 院系 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT 课程号, name VARCHAR(50) NOT NULL, credit DECIMAL(3,1) NOT NULL, teacher_id INT NOT NULL, capacity INT NOT NULL DEFAULT 60 COMMENT 容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE student_course ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2) COMMENT 成绩, selected_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_sc_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_sc_course FOREIGN KEY (course_id) REFERENCES course(id), CONSTRAINT uk_student_course UNIQUE (student_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;四张表里student_no、teacher_no、course_no都用了业务唯一键而不是把自增主键暴露给用户。这样做的价值在于登录页输入学号、导入 Excel 按学号匹配、页面上显示的都是业务号自增 id 只作为内部外键引用避免业务数据迁移时 id 变更引起一串外键问题。student_course表是整个系统最关键的关联表。先删课程再删学生、先删教师再删课程都必须考虑这张表上的外键。uk_student_course唯一索引是防止重复选课的最后一道防线哪怕程序里漏判数据库也能兜住。selected_count是一个冗余字段目的在于课程列表页直接显示已选人数不需要每次 join student_course 做 count查询性能会好很多但代价是选课和退课的事务里必须同步更新这个字段。字段类型说明scoreDECIMAL(5,2)0.00 ~ 100.00教师打分写入student_idINT学生主键外键指向 student.idcourse_idINT课程主键外键指向 course.idselected_timeDATETIME选课时间默认当前时间idINT关联记录主键3.2 工程目录与 MyBatis XML 的存放位置拿到打包好的源码我首先看目录结构而不是看代码。SSM 工程的规范程度基本靠目录就能判断。一个标准的分层结构应该长这样src ├── main │ ├── java │ │ └── com/sample/edu │ │ ├── controller # 处理请求返回页面或跳转 │ │ ├── service # 业务逻辑与事务边界 │ │ ├── mapper # MyBatis 接口 │ │ └── entity # 对应的实体类 │ ├── resources │ │ ├── mapper # Mapper XML 文件 │ │ ├── jdbc.properties │ │ └── applicationContext.xml │ └── webapp │ ├── WEB-INF │ │ └── jsp # JSP 页面 │ └── static # Bootstrap、CSS、JS分层的基本原则是 Controller 不直接写 SQLService 不直接处理 HttpServletRequest。Controller 只做参数接收和视图转发Service 负责业务规则和事务Mapper 接口只声明方法真正 SQL 在 XML 里。如果某个资源把 SQL 写在 Controller 里或者 Service 类里大量操作 HttpSession那这个工程的可维护性基本为零建议当参考骨架来用而不是直接交付。Mapper XML 的位置是我每次必查的地方。常见做法是把 XML 放在src/main/resources/mapper目录下并在 applicationContext.xml 里指定classpath:mapper/*.xml。但有些老工程喜欢把 XML 和接口放在同一个包下那就必须在 pom.xml 里显式声明资源目录否则打包后 Mapper XML 不会出现在 classpath 中运行期会报“Invalid bound statement”。build resources resource directorysrc/main/resources/directory /resource resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build这段配置把src/main/java下的 XML 也当作资源打入最终产物。很多同学在 IDEA 里跑时没问题因为 IDEA 编译时会把源码目录下的 xml 拷贝到 target可一执行mvn clean package打 war 包到 Tomcat 里就找不到 XML这就是经典的环境差异。我把这个配置叫作“后悔药”遇到 Invalid bound statement 时先检查 target/classes 里有没有对应 xml没有就是资源打包配置的问题。4. 三个核心流程的实现增删改查、打分、选课4.1 管理员对课程的增删改查完整链路这样串管理员的课程管理是这套系统里最典型的 CRUD 场景。它的价值不在于三个方法有多难写而在于你能否分清哪段代码该放在哪一层。Controller 里不该出现 SQLService 里不该直接写HttpSessionMapper XML 里只放最纯粹的 SQL这是 SSM 面试里最常被考察的点。Controller RequestMapping(/admin/course) public class CourseController { Autowired private CourseService courseService; GetMapping(/list) public String list(Model model) { model.addAttribute(courseList, courseService.findAll()); return admin/course_list; } PostMapping(/add) public String add(Course course) { courseService.add(course); return redirect:/admin/course/list; } GetMapping(/delete) public String delete(RequestParam(id) Integer id) { courseService.deleteById(id); return redirect:/admin/course/list; } }GetMapping和PostMapping是 Spring 5 的写法老资源里如果用的是RequestMapping也可以但建议统一成这种更明确的映射。Course对象直接作为方法入参SpringMVC 会把表单里的courseNo、name、credit等字段自动绑定到实体属性省去手动request.getParameter的重复劳动。redirect:/admin/course/list这个写法是 PRG 模式POST 提交后重定向到列表页避免用户刷新浏览器时重复提交表单。添加课程后下一步是删除课程的坑。课程被选课后student_course表里已经有记录直接 DELETE 会触发外键约束报错。所以 Service 层必须处理关联数据Service public class CourseServiceImpl implements CourseService { Autowired private CourseMapper courseMapper; Autowired private StudentCourseMapper studentCourseMapper; Transactional public void deleteById(Integer courseId) { studentCourseMapper.deleteByCourseId(courseId); courseMapper.deleteByPrimaryKey(courseId); } }Transactional保证两步操作要么都成功要么都回滚。如果先删除选课记录、再删除课程第二步失败了前面的删除也能回滚不会出现课程还在但选课记录被清掉的脏数据。Mapper XML 里删除选课记录对应一条简单的语句delete iddeleteByCourseId parameterTypeint DELETE FROM student_course WHERE course_id #{courseId} /delete4.2 教师打分更新分数还是保留历史成绩教师打分功能在这类系统中通常有两种做法一种是在student_course表里直接更新score字段另一种是单独建一张成绩表。大多数课设和简单教务系统用的是第一种因为它够直观页面也简单。但你要清楚它的边界如果老师给同一学生多次打分旧分数会被覆盖系统不会有任何历史痕迹。Transactional public void scoreStudent(Integer studentCourseId, BigDecimal score) { if (score null || score.compareTo(BigDecimal.ZERO) 0 || score.compareTo(new BigDecimal(100)) 0) { throw new RuntimeException(分数必须在0到100之间); } studentCourseMapper.updateScore(studentCourseId, score); }使用BigDecimal而不是Float或Double是成绩系统的入门常识。浮点数算 0.1 加 0.2 都会出现精度问题保存考试成绩时绝不能冒这个险。后端校验必须做前端只是体验优化别人绕过页面直接 POST 一个 120 分的成绩后端无法拦截的话就成了漏洞。update idupdateScore parameterTypemap UPDATE student_course SET score #{score} WHERE id #{studentCourseId} /update这里parameterType用了map因为入参是两个值Controller 把studentCourseId和score传进 ServiceService 再组装成 Map 传给 Mapper。另一种更规范的做法是定义一个 ScoreParam 对象或直接用Param注解。如果你打算把这个教务系统做成能长期维护的产品我会建议把成绩历史单独拆一张score_record表记录分数、时间、录入人student_course里的 score 只保留当前成绩。那样教师打分就不是覆盖而是新建记录同时回写当前分数。4.3 学生选课唯一索引和事务控制缺一不可选课是整个系统里最值得花时间看的功能因为它不仅是简单的 INSERT还涉及容量校验、重复校验和并发问题。学生端先看到一个课程列表点选课后提交课程 id 和学生 idService 层按“查课程 → 校验容量 → 插入选课记录 → 更新已选人数”的顺序处理。Transactional public void enroll(Integer studentId, Integer courseId) { Course course courseMapper.selectByPrimaryKey(courseId); if (course null) { throw new RuntimeException(课程不存在); } if (course.getSelectedCount() course.getCapacity()) { throw new RuntimeException(该课程人数已满); } int result studentCourseMapper.insert(studentId, courseId); if (result 0) { throw new RuntimeException(你已经选过这门课不要重复提交); } courseMapper.increaseSelectedCount(courseId); }这段逻辑里有三个细节值得注意。第一先查课程再判断容量这是一种常见的读取方式但遇到两个学生同时抢最后一门课的场景两个请求可能都读到selected_count 59、capacity 60然后都通过校验并 INSERT 成功最终人数变成 61。真正严谨的做法是对 course 行加SELECT ... FOR UPDATE悲观锁或者在 UPDATE 已选人数时带上WHERE selected_count capacity条件。简单系统里未必会做到这一层但你得知道边界在哪。第二insert返回 0 的情况依赖数据库层的uk_student_course唯一索引程序还没判断重复前数据库已经拒绝了重复插入这是最好的兜底。第三插入和更新人数必须放在同一个事务里否则会出现选课记录有了但列表上人数没变的情况。对应的 Mapper XML 只有两条语句insert idinsert parameterTypemap INSERT INTO student_course(student_id, course_id) VALUES(#{studentId}, #{courseId}) /insert update idincreaseSelectedCount parameterTypeint UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} /updateincreaseSelectedCount用selected_count selected_count 1这种原子自增而不是先查出来再回写是因为它能在并发下少发生一次脏读。这个细节老工程师一眼就能看出来面试或者答辩时主动提出来比背一百遍 MVC 概念有用得多。5. 避坑与常见问题排查五条血泪经验5.1 Maven 仓库配置错了够你折腾一晚上现象项目导入后 IDEA 一直转圈jar 包下载到一半停止或者明明本地有依赖还反复报 “Cannot resolve symbol”执行mvn clean install时提示某个插件找不到。原因Maven 默认中央仓库在外网访问速度不稳定另一个坑是本地仓库里留有.lastUpdated后缀的失败缓存文件Maven 看到这个文件会认为依赖下载失败不再重试。多个镜像源混用时不同 jar 可能从不同仓库下载元数据互相冲突也会出现怪异报错。解决打开 Maven 安装目录下conf/settings.xml配一个稳定镜像源mirror同时检查localRepository路径确保不能带空格。然后删除本地仓库下所有*.lastUpdated文件重新执行mvn clean compile。如果你不知道本地仓库在哪命令行里执行mvn help:evaluate -Dexpressionsettings.localRepository -q -DforceStdout它会直接打印路径省去翻 config 的时间。5.2 Tomcat 与 JDK 版本不匹配Tomcat 直接翻车现象启动 Tomcat 后控制台一堆ClassNotFoundException: javax.servlet.*或者ClassCastException: jakarta.servlet.ServletRequest cannot be cast项目页面全是 500。原因Spring 5.x 底层用的是javax.servlet命名空间而 Tomcat 10 之后全面切换到jakarta.servlet命名空间。老 SSM 源码大多是针对 Tomcat 8/9 写的硬塞进 Tomcat 10 就像把汽油发动机塞进柴油车接口都不一样。解决最简单的方案是用 Tomcat 8.5 或 Tomcat 9.0 运行JDK 用 1.8。如果你已经在用 Tomcat 10也不是不能修但要全局替换javax.servlet为jakarta.servlet并且把 Spring 升到 6.x涉及改动面很大。对这种 SSM 老资源回归到 Tomcat 8.5 是性价比最高的做法不要盲目追新版本。5.3 MyBatis 报 Invalid bound statementXML 根本没进 classpath现象项目能启动Controller 也能跳转一执行需要查数据库的方法就报org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): ...CourseMapper.findAll。IDE 里直接运行没报错打成 war 包部署到 Tomcat 才出现。原因这基本是 Mapper XML 没有出现在target/classes或 war 包的WEB-INF/classes里。IDE 的增量编译有时会帮把src/main/java下的 xml 一并输出到 target所以开发环境正常但mvn clean package是全量构建没有在 pom.xml 里声明src/main/java下的 xml 为资源干净构建后它们就被丢弃了。解决参考上一章给出的buildresources配置把源码目录下的 xml 也纳入资源打包同时检查 applicationContext.xml 里的mapperLocations是否写对路径。然后再检查接口的 namespace 是不是填成了mapper.CourseMapper而不是全限定名com.sample.edu.mapper.CourseMapper。用jar tf target/xxx.war | grep xml能直接验证 war 里到底有没有这个文件这是最快速的定位手段。5.4 外键约束挡删除关联数据没清理干净现象管理员在页面上删除一门课程或一个教师时页面报 500控制台出现Cannot delete or update a parent row: a foreign key constraint fails甚至提示fk_sc_course。原因数据库中确实配置了外键student_course表里还有记录引用着课程 id。直接删除主表记录数据库出于完整性拒绝执行。不少课设为了省事不建外键但资源里既然建了外键删除操作就必须先处理关联表。解决在 Service 里把删除主记录前先删子记录的逻辑补齐放在同一个事务里。更稳妥的做法是不做物理删除给课程表加一个status TINYINT字段删除时执行UPDATE course SET status 0 WHERE id ?所有列表查询强制带WHERE status 1。逻辑删除的代价是查询要记得过滤但好处是不会触发外键问题也能留操作痕迹。如果你要改造这套系统我建议把课程和教师删除都改成逻辑删除这是能快速提升系统完成度的低风险改动。5.5 中文乱码三个环节的编码必须统一现象JSP 页面显示的中文变成??输入中文保存后数据库里是乱码从数据库读出来又是乱码三种位置的表现还不一样。原因中文从浏览器到 Tomcat、再到 MySQL要穿过三条链路。浏览器页面编码、Tomcat 的 URI 编码、JDBC 连接参数、MySQL 表字符集任何一个环节不是 UTF-8就会出现乱码。最常见的是 jdbc.url 少了characterEncodingutf8或者建表时用了默认的 latin1。解决按三层排查。第一层JSP 页面头部加% page contentTypetext/html;charsetUTF-8 %静态页面里加meta charsetUTF-8。第二层Tomcat 的 server.xml 里给 Connector 加URIEncodingUTF-8。第三层数据库连接 url 带上useUnicodetruecharacterEncodingutf8建表统一用utf8mb4。如果项目里已经进了乱码数据用ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4转一下你会发现旧数据无法自动恢复所以这个问题的重点是前置预防。顺手在 web.xml 加一个CharacterEncodingFilter强制设置请求编码几乎能挡住一半的中文问题。6. 从打包到验收五步验证这套教务系统真的能跑拿到资源后我最厌恶的状态是“明明编译过了却不敢交付”。所以我现在有一套固定的验收流程不管给谁接手我都会走一遍。第一步执行mvn clean package -DskipTests打出干净 war 包用ls -lh target/*.war确认产物存在。第二步用jar tf target/xxx.war | grep mapper/.*xml检查 Mapper XML 是否被打进 war这步能提前暴露第 5.3 节说的 Invalid bound statement。第三步把 war 复制到 Tomcat 的 webapps 目录启动前先确认配置文件里的数据库密码和本地一致。第四步是核心验证动作跑一遍完整的业务冒烟用例验证动作预期结果管理员登录进入后台首页能看菜单新增一门课程课程列表出现新记录学生端课程列表同步可见学生选课选课记录新增一条课程列表已选人数加一教师打分分数写回选课记录列表展示成绩删除课程不报外键错误关联选课记录被清理第五步故意做一次容错测试让同一学生重复选同一门课程页面必须给出提示而不是直接报 500。这条过了说明数据库唯一索引和 Service 层校验都正常。如果这套系统还要继续扩展我会建议加一个登录拦截器在 SpringMVC 配置里注册一个 HandlerInterceptor拦截所有业务路径检查 Session 里有没有登录用户没有就跳回登录页。这个小功能大约二十行代码能让系统从“能跑”变成“像个产品”。从那以后我每次把一套 SSM 工程交付给别人都会强制走一遍这五步验证流程看一眼 war 落盘看一眼 Mapper XML 是否在 war 里看一遍数据库脚本再做一轮新增课程、学生选课、教师打分的冒烟测试最后故意触发一次重复选课确认兜底逻辑存在。这个习惯让我少挨了不少骂也让很多原本躺在压缩包里的代码真正变成能用的系统。希望帮到你。本文还有配套的精品资源点击获取