
简介这是一套基于SSM框架的学生成绩管理系统完整项目适合Java Web初学者、课程设计或期末作业使用。项目采用Spring、Spring MVC与MyBatis组合涵盖业务层、控制层与持久层完整代码配套数据库初始化脚本和Eclipse工程配置。资源共78个文件包含13个Java源文件、13个Class文件、8个JSP页面、14个XML配置及17个依赖Jar包另有properties、SQL脚本等压缩包大小9.37MB目录结构清楚便于直接导入Eclipse配合JDK8与Tomcat9运行。当前已有914人学习下载。借助该项目可掌握SSM三大框架的整合方式、MVC分层开发流程、MyBatis增删改查操作以及前端JSP与后端交互的实现思路同时也能熟悉web.xml、applicationContext.xml、mybatis-config.xml等核心配置文件的写法适合作为练手项目逐步调试与二次开发。1. SSM 学生成绩管理系统几乎所有课设的起点也是你查漏补缺的镜子学生成绩管理系统是 Java Web 里被写烂了的题目但正因为被写烂了它才最适合用来检验你对 SSM 的理解到底停在「配置能跑」还是「原理能说」。这个系统本身无非是学生、课程、成绩三张表再加一个登录页、几个 CRUD 列表但把用户、角色、权限、统计报表都塞进来之后SSM 的边界就开始模糊了。网上那些「(附源码) 基于 SSM 的学生信息管理系统的设计与实现」的毕设项目十有八九把 Controller 做成万能类把 Service 写成空壳把 SQL 堆在 Java 里——代码能跑但一问事务就露馅。这篇文章顺着这个最常见的毕设题目把 SSM 的请求流转、配置边界、成绩查询与统计的 SQL 写法讲透最后给一份答辩前必查的坑位清单。适合两类人准备做课设的在校生以及入职一两年、想确认自己没把 SSM 用歪的初级开发。2. 先理解 SSM 在成绩系统里的分工三层各管什么2.1 一个成绩查询请求是怎么穿过三个框架的成绩管理系统的典型功能是「按学生查成绩列表」。这个请求进来之后SSM 的处理顺序是固定的浏览器把 GET /score/list?studentId1 发给 TomcatSpringMVC 的 DispatcherServlet 接到请求通过 HandlerMapping 找到对应的 Controller 方法Controller 不碰数据库它调用 Service 接口Service 里如果加了 Transactional就在这里开启事务Service 再调 Mapper 接口Mapper 是个空接口真正干活的是 MyBatis 在启动时通过 JDK 动态代理生成的实现类实现类读取 XML 里的 SQL交给 SqlSession 执行最后把 ResultSet 映射成对象返回。这一条链路决定了三层各自的职责边界。Controller 只做参数接收、参数校验和视图/JSON 的拼装它不该知道 SQL 长什么样。Service 是业务逻辑和事务的边界成绩的合法性校验、平均分计算、权限判断都在这层。Mapper 是纯粹的数据库访问层一个方法对应一条或一组 SQL。我在实际项目里见过最典型的错误是为了省事直接让 Controller 注入 Mapper跨过 Service 查成绩。单看功能没问题但事务注解没地方放多个 Mapper 调用无法保证原子性后面加业务逻辑时 Controller 会越来越胖。SSM 这个组合的真正价值就是把这条链路固定下来让每个人写代码时不用想自己该在哪一层。2.2 两个 Spring 容器和三个配置文件的边界SSM 的配置是新手翻车重灾区因为实际上有三个配置文件spring.xml 管 Service、数据源和事务spring-mvc.xml 管 Controller 和视图解析器mybatis-config.xml 管 MyBatis 自身的行为。问题出在组件扫描上。很多人图省事在 spring-mvc.xml 里写 context:component-scan base-packagecom.example/把 Service 和 Controller 一起扫了项目能跑但事务有时候生效有时候不生效排查半天。正确的做法是让两个容器各扫各的包。spring.xml 里只扫 service 和 mappermapper 用 MapperScannerConfigurer 扫接口包spring-mvc.xml 里只扫 controller。原因在于 SpringMVC 的容器是子容器Spring 的容器是父容器Controller 能注入 Service 是因为子容器能看到父容器的 Bean但 Service 注入不了 Controller。如果让两个容器扫到同一个类会出现两个实例一个被事务代理包裹一个没有注入到哪个取决于装配顺序事务就随机失效。mybatis-config.xml 则负责开启驼峰映射、配置懒加载、注册别名和插件它不参与 Bean 管理。mapUnderscoreToCamelCase这个开关值得单独说。数据库字段一般是 student_no、course_name 这样的下划线风格Java 属性是 studentNo、courseName不开这个开关查询结果映射出来全是 null手动写 resultMap 又臭又长。在 mybatis-config.xml 里加上setting namemapUnderscoreToCamelCase valuetrue/只要别名不冲突绝大部分映射都能省掉。2.3 事务边界放 Service 层别放在 Controller成绩录入功能至少要写两条数据往 score 表插一条成绩往 operate_log 表插一条操作记录。这两条必须同时成功或同时失败否则就是脏数据。事务要放在 Service 层的方法上而不是 Controller 上因为 Controller 是并发入口一个方法里可能有多个分支调用不同的 Service事务粒度不好控而且 Controller 里捕获异常后容易把事务状态搞乱。常见的写法是这样Service public class ScoreServiceImpl implements ScoreService { Autowired private ScoreMapper scoreMapper; Autowired private OperateLogMapper operateLogMapper; Transactional(rollbackFor Exception.class) Override public void addScore(Score score, String operator) { scoreMapper.insert(score); OperateLog log new OperateLog(); log.setAction(ADD_SCORE); log.setOperator(operator); log.setCreateTime(new Date()); operateLogMapper.insert(log); } }注意 rollbackFor Exception.class 是必须的。Spring 默认只在抛出 RuntimeException 时回滚而受检异常比如 IOException不会触发回滚。成绩写入时如果调了外部接口或者写了文件抛出的受检异常被默认策略放过成绩表里就会出现半截数据。把 rollbackFor 指到 Exception.class是让所有异常都回滚。3. 用 SSM 把成绩 CRUD 跑通建表、依赖和三层代码落位3.1 成绩系统的核心表和 Maven 依赖学生成绩管理系统的表结构比想象中的简单三张业务表加一张用户表就够别一上来就设计十几张表。student 表存学生基础信息course 表存课程score 表是学生和课程的多对多关系表每行一个学生一门课的成绩。字段上注意 score 表的 student_id 和 course_id 要建联合唯一索引防止同一个学生同一门课被插入两条记录这个约束在代码里也得查一遍但不能只靠代码数据库约束才是兜底。CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, class_id INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(50) NOT NULL, credit DECIMAL(3,1) DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;Maven 依赖在 pom.xml 里核心就五个spring-webmvc、spring-jdbc、mybatis、mybatis-spring、druid 连接池。分页插件 pagehelper 也是必需的成绩列表不分页的话数据一多页面直接卡死。依赖作用关键配置点spring-webmvcSpringMVC 框架DispatcherServlet 拦截/spring-jdbc提供事务管理DataSourceTransactionManagermybatisORM 框架mapper XML 位置mybatis-spring整合 MyBatis 与 SpringMapperScannerConfigurer 扫包druid数据库连接池maxActive 按并发量调pagehelper物理分页自动改写 SQLpom 里最容易出错的是 mybatis-spring 和 Spring 的版本搭配。Spring 5.x 对应 mybatis-spring 2.xSpring 4.x 用 1.3.x版本不匹配会在启动时报 AbstractMethodError这个错不在报错信息里直观提示版本很多人栽在这。3.2 成绩查询的 SQL 写在哪Mapper XML 的多表关联成绩列表不会只查 score 表页面要显示学号、姓名、课程名和分数这些字段分布在三张表里。多表关联的 SQL 写在 Mapper XML 里用 实现而不是在 Java 里先查学生再循环查成绩——那样会产生 N1 次查询50 个学生就是 101 条 SQL。select idselectScoreList resultTypemap SELECT sc.id AS scoreId, s.stu_no AS stuNo, s.name AS studentName, c.course_name AS courseName, sc.score FROM score sc JOIN student s ON sc.student_id s.id JOIN course c ON sc.course_id c.id where if teststudentId ! null AND sc.student_id #{studentId} /if if testcourseId ! null AND sc.course_id #{courseId} /if if testkeyword ! null and keyword ! AND (s.stu_no LIKE CONCAT(%, #{keyword}, %) OR s.name LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY sc.id DESC /select这段 SQL 的逻辑说明resultType 用 map 而不是实体类原因是查询结果带了多张表的字段定义一个 ScoreVO 类更规范但 map 在快速验证时最省事 标签会自动去掉第一个多余的 AND这是 MyBatis 动态 SQL 里最常用的特性三个 分别对应按学生筛选、按课程筛选、按关键字模糊搜索页码页面上那个搜索框传 null 就不用拼条件。传参用 #{} 会被预编译能防 SQL 注入而 ${} 是字符串拼接只在表名、排序字段这类无法预编译的位置用并且得自己校验白名单。3.3 分页、JSON 返回和控制器的参数绑定成绩列表用 PageHelper 分页用法是固定的三行PageHelper.startPage(pageNum, pageSize) 必须在查询语句之前调用它只对紧随其后的第一条查询生效。startPage 之后如果又执行了别的查询分页会被带偏。返回值用 PageInfo 包裹total 和 list 都在里面。Controller RequestMapping(/score) public class ScoreController { Autowired private ScoreService scoreService; ResponseBody GetMapping(/list) public PageInfoMapString, Object list( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Integer studentId, Integer courseId) { PageHelper.startPage(pageNum, pageSize); ListMapString, Object list scoreService.getScoreList(studentId, courseId); return new PageInfo(list); } }Controller 参数绑定的细节pageNum 和 pageSize 给了默认值防止前端不传导致查询全部数据studentId 和 courseId 不设 defaultValue前端不传就是 nullXML 里的 判断自动跳过条件。ResponseBody 配合 Jackson 把返回值序列化成 JSON 返回给前端Fastjson 和 Jackson 选哪个都行但注意日期字段格式直接在实体属性上加 JsonFormat(pattern yyyy-MM-dd HH:mm:ss) 最省事全局配置 ObjectMapper 有时会被 XML 里的配置覆盖。成绩录入的代码套路一样Controller 接收 JSONService 里先查一遍联合唯一约束再 insert或者直接捕获数据库的 DuplicateKeyException。高并发下查再插入中间有窗口期依赖数据库唯一索引兜底更稳妥。4. 成绩统计与排名SSM 项目里最有技术含量的 SQL4.1 按课程排名的两种写法窗口函数和用户变量成绩排名的需求很经典每个课程按分数从高到低排名同分并列。MySQL 8.0 用窗口函数一行搞定ROW_NUMBER() OVER (PARTITION BY course_id ORDER BY score DESC) 是直接排名RANK() 是同分并列排名DENSE_RANK() 是同分并列且不跳号。但很多毕设和公司老库还在用 MySQL 5.7不支持窗口函数这时候得用用户变量模拟。SELECT stu_no, student_name, course_name, score, rank_no FROM ( SELECT s.stu_no, s.name AS student_name, c.course_name, sc.score, rank : IF(prev_course sc.course_id, rank 1, 1) AS rank_no, prev_course : sc.course_id FROM score sc JOIN student s ON sc.student_id s.id JOIN course c ON sc.course_id c.id ORDER BY sc.course_id, sc.score DESC ) t这段 SQL 的执行逻辑内层查询先按课程分组、分数降序排列保证同一门课的学生挨在一起用户变量 rank 用 IF 判断当前行的课程和上一行是否相同相同就加 1不同就重置为 1prev_course 记录上一行的课程 ID。注意外层查询必须套一层子查询因为 MySQL 对 SELECT 中用户变量的赋值顺序没有保证直接在同一个 SELECT 里读取 rank 会拿到上一行的旧值。在 MyBatis 的 XML 里写这段 SQL 前要先用 Navicat 或命令行验证一遍用户变量在预编译环境下没有任何特殊问题但排序字段要写死不能拼参数。MySQL 8.0 的环境里没必要用用户变量窗口函数可读性强太多执行计划也更稳定。但如果你做毕设答辩评委很可能会问「为什么老系统不用窗口函数」能答出 MySQL 版本的差异本身就是加分项。4.2 及格率、优秀率和分数分布怎么算成绩管理系统的统计页一般要展示每门课的平均分、最高分、最低分、及格率、优秀率。及格率在 SQL 里用聚合函数配合 CASE WHEN 算不需要在 Java 里循环统计。分数分布也一样用 CASE WHEN 把分数分桶GROUP BY 桶编号。SELECT c.course_name, COUNT(*) AS total_cnt, ROUND(AVG(sc.score), 2) AS avg_score, MAX(sc.score) AS max_score, MIN(sc.score) AS min_score, ROUND(SUM(CASE WHEN sc.score 60 THEN 1 ELSE 0 END) / COUNT(*), 4) AS pass_rate, ROUND(SUM(CASE WHEN sc.score 90 THEN 1 ELSE 0 END) / COUNT(*), 4) AS excellent_rate FROM score sc JOIN course c ON sc.course_id c.id GROUP BY sc.course_id, c.course_name分数分布用另一个查询常见的做法是分段统计60 以下、60 到 69、70 到 79、80 到 89、90 以上。CASE WHEN 的区间判断放在 GROUP BY 里每个区间算一个编号。SELECT CASE WHEN sc.score 60 THEN 0-59 WHEN sc.score 70 THEN 60-69 WHEN sc.score 80 THEN 70-79 WHEN sc.score 90 THEN 80-89 ELSE 90-100 END AS score_range, COUNT(*) AS cnt FROM score sc GROUP BY score_range ORDER BY score_range这段 SQL 的要点GROUP BY 后面可以直接跟 SELECT 里的别名 score_rangeMySQL 允许这样写别的数据库不一定ORDER BY score_range 是按字符串排序分段名称如果改成了中文区间名排序会按拼音首字母走不一定是想要的顺序稳妥的办法是在 CASE WHEN 里把分段编号返回外层再用编号排序。统计结果在 MyBatis 里的返回类型可以是 ListMapString, Object每一行是一个分段区间前端拿到之后直接渲染柱状图。用 map 接收聚合结果很常见因为没有一个固定的实体类跟这种临时结构对应。4.3 动态 SQL 在成绩筛选场景的完整用法成绩列表通常有多个筛选项按班级、按课程、按分数区间、按姓名。这些筛选条件组合起来用 MyBatis 的动态 SQL 处理是最标准的做法。核心标签是 、 和 。 负责拼装条件时自动去掉多余的 AND 或 OR 判断参数是否为空字符串 处理 IN 集合参数。select idselectScoreByCondition resultTypemap SELECT s.stu_no, s.name, s.class_id, c.course_name, sc.score FROM score sc JOIN student s ON sc.student_id s.id JOIN course c ON sc.course_id c.id where if testclassId ! null AND s.class_id #{classId} /if if testcourseIdList ! null and courseIdList.size() 0 AND c.id IN foreach collectioncourseIdList itemcid open( separator, close) #{cid} /foreach /if if testminScore ! null AND sc.score gt; #{minScore} /if if testmaxScore ! null AND sc.score lt; #{maxScore} /if /where /select注意 XML 里的小于号需要用 转义直接写 会解析错误。foreach 的 collection 属性参数是 List 时写 list是数组写 array是 Param 指定的名字就写那个名字。IN 条件里的集合如果为空 的 size() 0 先拦截住避免生成 IN () 的非法 SQL。5. SSM 项目答辩前必查8 个隐性坑与验证方法成绩管理系统通常会作为课设或毕设验收但代码能跑不等于没问题。下面这 8 个问题是评委/面试官最常追着问的也是实际生产环境里最容易翻车的点。事务不生效的三种情况。第一种是自调用同一个 Service 类里的方法 A 调方法 BB 上标了 Transactional但事务不会开启因为 Spring 的 AOP 代理只有从外部调用才拦截内部 this 调用绕过代理第二种是 catch 住异常不往外抛事务感知不到异常就不会回滚第三种是方法不是 publicTransactional 只对 public 方法生效。验证方法是写一个必失败的测试场景插入成绩后手动抛异常查数据库确认没有半截数据。更稳的做法事务边界不要跨网络调用比如在事务里调远程接口网络超时会长时间占用数据库连接。MyBatis 一级缓存造成的脏读。MyBatis 默认开启一级缓存同一个 SqlSession 内执行相同 SQL 会返回缓存结果但缓存基于条件完全相同和参数完全相同的判断且 session 关闭即失效。在成绩修改后立刻查列表如果查到旧数据大概率是同一个 session 里有查询未提交或 mapper 缓存未刷新。排查办法在配置里设置 localCacheScopeSTATEMENT 关闭一级缓存看问题是否消失。连接池参数不合理。Druid 默认的 initialSize0maxActive8对课设系统够用但答辩时如果演示并发查询8 个连接很快耗尽页面会卡住。调参逻辑maxActive 按并发峰值的两倍设置initialSize 设成跟 minIdle 一致maxWait 设 3000 毫秒超时直接报错而不是无限等。页码越界返回空数据。前端传 pageNum999 时PageHelper 返回空 list很多系统没做处理。常见方案是在查询前校验 pageNum 0 时重置为 1超过总页数时返回最后一页数据避免前端拿到空列表以为系统坏了。字符集不一致导致乱码。建表用了 utf8mb4连接串没加 characterEncodingutf8 的话中文可能存进去就是问号。数据库连接 URL 里统一加 characterEncodingutf8mb4。另外Tomcat 8.5 以上的 URI 编码默认 UTF-8如果用了老版本GET 请求的中文参数还得改 server.xml 的 URIEncoding。${} 误用导致的 SQL 注入。成绩系统的排序功能如果用了 ORDER BY ${sortField}sortField 如果直接拼前端传的值用户传 score desc; drop table score 就能删表。必须对 sortField 做白名单校验排序字段只允许 score、stu_no、course_name 这几个值排序方式只允许 asc 和 desc。SQL 执行计划没检查。成绩表数据量到几万条时关联查询如果没走索引会慢到秒级。验证方法在 SQL 前加 EXPLAIN看 type 字段是否出现 ALL如果有就是全表扫描。score 表的 student_id、course_id 建联合索引student 表的 stu_no 建唯一索引这些在建表 SQL 里最好就带上。JSON 序列化性能。有些项目为了图省事把 PageInfo 整个返回给前端PageInfo 里带了 navigatepageNums 等一堆分页导航属性数据量一大 JSON 就大。前端只用到 list 和 total 的时候手动拼一个 Result 对象只返回这两个字段响应体积能缩一半。验证方法用一条 curl 命令就够了curl http://localhost:8080/score/list?pageNum1pageSize10看返回的 JSON 里 total 是否与实际数据一致再看耗时。统计接口用 curl 加 -w %{time_total} 看响应毫秒数如果超过 1 秒说明 SQL 需要优化。运行环境用 MySQL 8.0 的窗口函数做排名用 5.7 的用户变量写法做兼容两边都能拿高分。本文还有配套的精品资源点击获取