ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaWeb教务管理系统课程设计:环境配置、核心业务与答辩验收

JavaWeb教务管理系统课程设计:环境配置、核心业务与答辩验收

简介:这套教务管理系统源码面向JavaWeb初学者与课程设计者,基于MVC与DAO模式,采用Servlet、JSP、Filter和MySQL实现登录、找回密码、修改密码、注销及学生、教师、教务员、管理员四类角色功能。学生端可完成成绩查询、选修与考级报名、学籍信息查看修改和考级成绩查询,教师端支持个人信息维护,教务员端涵盖成绩管理与报名名单管理,管理员端负责用户管理。压缩包共223个文件,约2.6MB,以82个Java源码、82个class和38个JSP页面为主,另含6个jar依赖、SQL脚本及XML配置,完整呈现从数据库到表现层的组织方式。已有11659人学习下载。按README配置Tomcat 7与JDK 1.8即可启动,可借助现有模块理解请求流转、过滤器控制与数据库访问,部分功能留有扩展空间,便于继续补充完善。

1. 教务管理系统 JavaWeb 项目:一份能跑通的课程设计底座长什么样

期末前两周,群里总会冒出同一个问题:教务管理系统 JavaWeb 项目拿过来改改能不能交。答案取决于你手上这套东西是完整链路还是只有页面的空壳。这类资源真正的价值不在界面,而在于它把学生、教师、课程、选课、成绩这几张核心表以及表之间的业务约束串起来了:选课要判容量和时间冲突,成绩要绑定教学班,排课要避开同一教师的时间重叠。拿到手先看三样:建表脚本带不带初始数据、Dao 层有没有真实 SQL、登录后能不能按角色跳到不同菜单。三样齐了它就是合格底座,缺一样,后面补的时间比从零写还多。适合课程设计、毕设起步,也适合想练 Servlet 或 SSM 分层的人。

2. 环境与数据库落地:从 JDK 到第一次启动的完整链路

很多人卡在第一步不是不会写代码,是环境版本对不上。一个教务管理系统 JavaWeb 项目从解压到能在浏览器里登录,中间要穿过 JDK、Maven、Tomcat、MySQL 四道关,每道关的版本组合都有脾气。这一章先把选型和建表讲透,再落到可复制的启动步骤。

2.1 技术栈盘点:Servlet+JSP、SSM 还是 Spring Boot

同一份「教务管理系统」标题下,实际可能是三种完全不同的技术栈。我一般按下面这张表快速判断,不用读完所有代码,看目录结构和 pom 就够。

判断依据Servlet+JSPSSM(Spring+SpringMVC+MyBatis)Spring Boot
目录特征有WEB-INF/web.xml,JSP 在webapp下有applicationContext.xml、spring-mvc.xml、mybatis-config.xml有Application.java启动类,application.yml
依赖只有 servlet-api、jsp-api、mysql 驱动有 spring-webmvc、mybatis-spring有 spring-boot-starter-web
部署打 war 丢进 Tomcat打 war 丢进 Tomcat内嵌 Tomcat,java -jar直接跑
上手难度低,但后期维护差中,XML 配置多低,但答辩时容易被追问原理
适合场景课程设计、需要讲清请求链路需要体现分层与框架能力演示为主,想省环境配置

课程设计场景我更倾向选 SSM:分层清楚,Controller/Service/Dao 三层写在目录里,答辩时老师一眼能看出你知道自己在做什么;Servlet+JSP 也不是不行,但 JSP 里塞while(rs.next())写多了,后面改一个字段要翻五个文件。Spring Boot 最快,代价是自动配置把细节藏起来,问到「一个请求进来怎么走到 Mapper」容易答不上来。

环境组合上,常见做法是 JDK 8、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、Maven 3.6 以上。JDK 8 不是怀旧,是因为老项目的javax.servlet包名在 JDK 11 之后的高版本 Tomcat 上会报ClassNotFoundException;MySQL 8 和 5.7 的差别在驱动类名和连接串参数,下面会具体写。

2.2 建表脚本与初始数据:五张核心表怎么读

拿到 SQL 文件先别急着执行,通读一遍能省掉后面一半的调试。教务系统的核心表就那几张:学生、教师、课程、选课记录、成绩。判断脚本写得好不好,看三点——主键是不是自增 BIGINT、学号工号这类业务编号有没有唯一索引、选课表有没有联合唯一约束。

-- 学生表:sno 建唯一索引,避免重复导入同一批学生 CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL COMMENT '学号,业务唯一键', name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', class_id BIGINT NOT NULL COMMENT '班级ID', major_id BIGINT NOT NULL COMMENT '专业ID', status TINYINT DEFAULT 1 COMMENT '1在读 0离校', UNIQUE KEY uk_sno (sno), KEY idx_class (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生'; -- 选课表:student_id + course_id 联合唯一,从库层面挡住重复选课 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, term VARCHAR(20) NOT NULL COMMENT '学期,如 2024-2025-1', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course_term (student_id, course_id, term), KEY idx_course (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录'; -- 课程表:selected 与 capacity 两个字段是后面并发扣减的关键 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, teacher_id BIGINT NOT NULL, credit DECIMAL(3,1) DEFAULT 0, capacity INT DEFAULT 60 COMMENT '容量上限', selected INT DEFAULT 0 COMMENT '已选人数', time_slot VARCHAR(50) COMMENT '如 周一-3-4节', UNIQUE KEY uk_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程';

第一段 SQL 里有两个设计点值得注意。uk_stu_course_term这个联合唯一索引把「同一学生、同一课程、同一学期」锁死,即使 Service 层校验漏了,数据库也会直接抛Duplicate entry,比事后人工查重靠谱。course表上的selected和capacity看似冗余(完全可以用count(*)算),但选课高峰期每次进来都 count 一次选课表,表一大就是慢查询,用冗余字段换读性能是教务系统的常规取舍。

提示:执行脚本前先确认字符集是utf8mb4,不是utf8。教师姓名、课程名里出现生僻字或者学生备注里带表情符号时,utf8会在插入阶段直接报错,而且报错信息不直观,容易误判成代码问题。

初始数据里重点看两处:sys_user表有没有 admin/teacher/student 三种角色的种子账号,密码字段是明文还是哈希。明文密码在课程设计里不致命,但如果后面要做「修改密码」功能,你得先知道原密码是怎么存的,否则改完登录不上。另一处看课程表有没有预置十来条课程数据,没有的话第一次进选课页是一条空列表,很难判断是查询写错还是本来就没数据。

2.3 导入与第一次启动:按顺序验证,别一次全点上

启动顺序错了,报错会互相掩盖。我一般的做法是分四步,每步只验证一件事。

第一步,验证数据库连通。改jdbc.properties,把库名、账号、密码换成自己的,然后单独跑一个测试类或者用命令行连一次。

# MySQL 8 必须带时区和 SSL 参数,5.7 可以省略但加上也不报错 jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/edu_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password

serverTimezone不配会在 MySQL 8 上抛The server time zone value is unrecognized;allowPublicKeyRetrieval=true是 MySQL 8 默认认证插件caching_sha2_password要求的,缺了会报公钥检索失败。这两条是新手最容易漏的,不是代码问题,但错误堆栈看起来像代码问题。

第二步,验证 Maven 依赖下载完整。命令行进项目根目录跑mvn clean package -DskipTests,看target下有没有打出 war。如果卡在某个依赖下载失败,多半是镜像源问题,换阿里云镜像重试,别急着改 pom 版本号——很多时候只是网络抖动,改版本反而引入新的不兼容。

# 跳过测试打包,课程设计项目里的测试类经常是空的或者过期的 mvn clean package -DskipTests # 打包产物一般在 target 下,名字类似 edu-system-1.0-SNAPSHOT.war ls target/*.war

第三步,配置 Tomcat。IDEA 里加一个 Tomcat Server 本地实例,Deployment 选项卡里加 Artifact,Application context 设成/edu。这一步最容易翻车的地方是 Artifact 选成了war exploded之外的选项,或者 context 留空导致访问路径变成根路径,和项目里写死的重定向地址对不上。

第四步,访问登录页。浏览器打开http://localhost:8080/edu/login.jsp,能看到页面说明静态资源通了;能登录进去说明数据库和 Mapper 通了;登录后菜单按角色不同说明权限拦截生效了。三级验证一次只暴露一类问题,比一股脑启动然后对着 500 页面猜要快得多。

3. 核心业务模块拆解:登录、选课、成绩三条主线

页面能打开只是开始,教务系统的分量全在业务约束上。这一章挑三个最容易被问、也最容易写错的模块:登录鉴权、选课并发、成绩统计。每个都给出可抄的实现和参数说明。

3.1 登录与角色权限:Filter + Session 的最小闭环

登录本身的逻辑不复杂:查用户、比对密码、写 Session、跳转。真正决定这套系统能不能用的,是登录之后每个请求有没有被挡住。常见做法是写一个Filter拦所有.do或/api/*路径,从 Session 里取用户,取不到就重定向到登录页。

// LoginFilter:拦截未登录访问,同时做角色粗粒度校验 public class LoginFilter implements Filter { // 白名单:登录页、登录接口、静态资源不拦 private static final Set<String> WHITE = new HashSet<>(Arrays.asList( "/login.jsp", "/user/login.do", "/css/", "/js/", "/images/" )); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); String path = uri.substring(request.getContextPath().length()); // 白名单直接放行,注意用 startsWith 兼容静态资源目录 for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(req, resp); return; } } HttpSession session = request.getSession(false); Object user = session == null ? null : session.getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 管理员路径只允许 role=1 通过,其他角色打回首页 if (path.startsWith("/admin/") && !"1".equals(session.getAttribute("roleId"))) { response.sendRedirect(request.getContextPath() + "/403.jsp"); return; } chain.doFilter(req, resp); } }

这段代码有三个参数要按项目实际改。WHITE白名单里的路径必须和web.xml或注解里配的url-pattern对齐,少写一个静态资源前缀,登录页就会变成无样式裸 HTML。session.getAttribute("loginUser")这个 key 要和登录接口里setAttribute的 key 完全一致,大小写都算。roleId的判断用的是字符串比较,因为 Session 里存的可能是 Integer,直接==比会在 127 以上出问题,统一转字符串能避开这个坑。

密码存储上,课程设计里 MD5 很常见,但建议至少加个固定盐再 MD5,登录时用同样方式算一遍比对。这不是为了安全级别,是为了答辩时被问到「密码明文存吗」有个能接住的说法。

3.2 选课模块:容量扣减与时间冲突的两道校验

选课是教务系统里唯一有并发压力的写操作。两个学生同时点「选课」,都读到selected=59、capacity=60,都判断还有余量,结果插了两条记录变成 61 人。这个场景在课程设计答辩时几乎是必问的。

正确写法是把判断和扣减压进一条 SQL,靠数据库的行锁保证原子性。

// CourseServiceImpl#selectCourse @Transactional(rollbackFor = Exception.class) public Result selectCourse(Long studentId, Long courseId, String term) { // 1. 时间冲突校验:同一学期已选课程里,有没有 time_slot 重叠的 int conflict = selectionMapper.countTimeConflict(studentId, courseId, term); if (conflict > 0) { return Result.fail("该时间段已有课程,不能重复选"); } // 2. 原子扣减:selected < capacity 不成立时影响行数为 0 int affected = courseMapper.increaseSelected(courseId); if (affected == 0) { return Result.fail("课程已满"); } // 3. 插入选课记录,唯一索引兜底重复选课 try { selectionMapper.insert(studentId, courseId, term); } catch (DuplicateKeyException e) { // 重复选课时把刚扣的名额还回去,保证数据一致 courseMapper.decreaseSelected(courseId); return Result.fail("请勿重复选课"); } return Result.ok(); }

对应的 Mapper SQL 只有一句,但它是整个模块的核心:

<!-- 只有 selected 小于 capacity 时才加一,返回影响行数供上层判断 --> <update id="increaseSelected"> UPDATE course SET selected = selected + 1 WHERE id = #{courseId} AND selected &lt; capacity </update>

参数说明:#{courseId}是课程主键;selected &lt; capacity这个条件写在WHERE里而不是先SELECT再判断,是整段逻辑的关键——InnoDB 在执行 UPDATE 时会对命中行加排他锁,并发进来只有一个能成功。@Transactional的rollbackFor = Exception.class不能省,Spring 默认只对RuntimeException回滚,DuplicateKeyException虽然是运行时异常,但显式写上能避免以后改成受检异常时踩坑。

时间冲突校验那条 SQL 一般长这样:把两个time_slot字符串解析成「星期 + 起止节次」再比对。如果表设计阶段就把time_slot拆成day_of_week、start_section、end_section三个整型字段,冲突判断会简单得多,直接start_section <= 已选end AND end_section >= 已选start。这也是我看建表脚本时特别留意的地方——字符串存时间段看着省事,写起校验来全是字符串切割。

3.3 成绩录入与统计:批量插入和分数段查询

成绩模块的写入特点是「一次提交一整张表」。教师选一门课,页面列出所有选课学生,填完分数一次性保存。逐条insert循环会产生 N 次数据库往返,三十人的班还能忍,一百多人就明显卡。标准做法是 MyBatis 的foreach批量插入。

<!-- 批量插入成绩,useGeneratedKeys 不需要,成绩表主键自增即可 --> <insert id="batchInsert" parameterType="java.util.List"> INSERT INTO score (student_id, course_id, term, score, input_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.studentId}, #{item.courseId}, #{item.term}, #{item.score}, NOW()) </foreach> ON DUPLICATE KEY UPDATE score = VALUES(score), input_time = NOW() </insert>

ON DUPLICATE KEY UPDATE这一段是给「教师改分」留的后路:成绩表上(student_id, course_id, term)建了唯一索引,重复提交同一学生的成绩不会报错,而是覆盖旧值。VALUES(score)在 MySQL 8.0.20 之后标记为弃用,新版本可以改成别名写法,但课程设计环境多半是 5.7 或 8.0 早期版本,用这个写法兼容性最好。

统计查询是答辩时容易出彩的地方。一个班一门课的成绩分布,一条 SQL 能出五个分数段:

-- 按分数段统计人数,CASE WHEN 比多次 count 查询快得多 SELECT SUM(CASE WHEN score >= 90 THEN 1 ELSE 0 END) AS excellent, SUM(CASE WHEN score >= 80 AND score < 90 THEN 1 ELSE 0 END) AS good, SUM(CASE WHEN score >= 70 AND score < 80 THEN 1 ELSE 0 END) AS medium, SUM(CASE WHEN score >= 60 AND score < 70 THEN 1 ELSE 0 END) AS pass, SUM(CASE WHEN score < 60 THEN 1 ELSE 0 END) AS fail, ROUND(AVG(score), 2) AS avg_score, COUNT(*) AS total FROM score WHERE course_id = #{courseId} AND term = #{term};

参数只有课程 ID 和学期两个,输出直接对应前端 ECharts 柱状图需要的数组。比起在 Java 里查全量再循环统计,这条 SQL 把计算压在数据库侧,数据量上去以后差距很明显。唯一要注意的是score字段如果允许 NULL(比如缓考),SUM里要加score IS NOT NULL,否则平均分会被拉低。

4. 分页、校验与导出:把 CRUD 做成能用的界面

后台管理系统的页面百分之八十是列表页,列表页的体验取决于三件事:分页对不对、参数校验全不全、导出能不能用。这一章按这个顺序拆。

4.1 MyBatis 分页与动态条件查询

学生列表要支持按学号、姓名、班级组合筛选,还要分页。手写LIMIT不是不行,但每加一个查询条件就要多写一段<if>,容易漏。用 PageHelper 能把分页逻辑从 SQL 里摘出去。

<!-- pom.xml 中引入,版本跟随项目已有的 mybatis 版本走 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.2</version> </dependency>
// StudentServiceImpl#pageQuery public PageInfo<StudentVO> pageQuery(StudentQuery query, int pageNum, int pageSize) { // 紧跟在 PageHelper.startPage 之后的第一次查询才会被分页 PageHelper.startPage(pageNum, pageSize); List<StudentVO> list = studentMapper.selectByCondition(query); return new PageInfo<>(list); }

关键点在注释里那句:startPage只对紧接着的第一次 Mapper 查询生效。如果中间插了一句别的查询(比如先查班级列表),分页就会作用到错误的 SQL 上,表现为总数对但数据不对,这个坑很隐蔽。pageNum从 1 开始,传 0 会查不出数据;pageSize建议在 Controller 层做上限,超过 200 直接截断,防止前端传个 99999 把整张表拉出来。

动态条件那段 XML 大概是这样:

<select id="selectByCondition" resultType="StudentVO"> SELECT s.id, s.sno, s.name, s.gender, c.class_name, m.major_name FROM student s LEFT JOIN class c ON s.class_id = c.id LEFT JOIN major m ON s.major_id = m.id <where> <if test="sno != null and sno != ''"> AND s.sno LIKE CONCAT('%', #{sno}, '%') </if> <if test="name != null and name != ''"> AND s.name LIKE CONCAT('%', #{name}, '%') </if> <if test="classId != null"> AND s.class_id = #{classId} </if> </where> ORDER BY s.sno ASC </select>

<where>标签会自动处理第一个AND,不用手写WHERE 1=1。LIKE用CONCAT拼而不是'%${sno}%',是为了走预编译,避免注入。这几条是 MyBatis 的基本功,但我在别人的课程设计里见过不少直接字符串拼接的写法,答辩时被问一句「这里能不能注入」就答不上来。

4.2 表单校验与统一返回结构

前端校验是为了体验,后端校验是为了数据。两边都要有,但不能只信前端。SSM 项目里最省事的后端校验是 JSR-303 注解加@Valid。

// 学生新增接口,@Valid 触发校验,BindingResult 收集错误 @PostMapping("/student/add") @ResponseBody public Result addStudent(@Valid @RequestBody StudentForm form, BindingResult br) { if (br.hasErrors()) { // 只取第一条错误信息返回,前端提示够用 return Result.fail(br.getAllErrors().get(0).getDefaultMessage()); } return studentService.save(form); }

StudentForm上对应加@NotBlank(message = "学号不能为空")、@Pattern(regexp = "^\\d{8,12}$", message = "学号格式不正确")这类注解。Result统一返回结构建议固定成code、msg、data三个字段,前端拦截器按code判断成功失败,不要一个接口返回布尔、另一个返回字符串,前端要写两套判断。

4.3 成绩单导出与课表打印

导出用 Apache POI,写XSSFWorkbook生成 xlsx。这一步的坑不在 API 复杂,而在响应头。

// 导出成绩单,注意文件名要做 URL 编码,否则中文名会乱码 @GetMapping("/score/export") public void export(HttpServletResponse response, @RequestParam Long courseId) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("UTF-8"); String fileName = URLEncoder.encode("成绩单.xlsx", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName); try (XSSFWorkbook wb = new XSSFWorkbook(); ServletOutputStream out = response.getOutputStream()) { Sheet sheet = wb.createSheet("成绩"); // 表头和数据行的写入略,重点是最后直接写流,不要再 forward wb.write(out); } }

Content-Disposition用filename*=UTF-8''这种 RFC 5987 写法,是为了让中文文件名在各浏览器下都能正常保存。如果写成filename=成绩单.xlsx,部分浏览器会显示成乱码或者直接丢掉文件名。另外方法返回void且直接操作response流,就不能再在方法上标@ResponseBody之外的返回处理,写完流也别再return一个视图名,否则会报响应已提交。

课表打印相对简单,本质是把选课记录按「星期 × 节次」铺成一张表格,前端用<table>加 CSS 合并单元格就行,不需要额外依赖。真要打印美观,加一段@media print样式把导航栏隐藏掉即可。

5. 部署与联调排查:五条血泪翻车记录

下面这些不是从文档里抄的,是这类项目在联调阶段反复出现的五类问题。每条按「现象、原因、解决」写,对着报错信息能直接对号入座。

5.1 五个高频翻车现场

第一条:中文全变问号或者乱码。

现象是登录后学生姓名显示成???,或者提交表单保存进库变成乱码。原因通常有三层叠加:Tomcat 8 的 GET 请求默认按 ISO-8859-1 解码、POST 请求没配CharacterEncodingFilter、数据库连接串没带characterEncoding=utf8。解决要三层一起改——web.xml里把CharacterEncodingFilter放到所有 Filter 最前面并设forceEncoding=true;Tomcat 的server.xml里 Connector 加URIEncoding="UTF-8";连接串补上useUnicode=true&characterEncoding=utf8。只改一层往往看起来好了,换个页面又乱。

第二条:日期字段前后端对不上,报 400。

现象是新增学生时填了出生日期,提交直接 400,日志里一行Failed to convert value of type String to Date。原因是 SpringMVC 默认不接受yyyy-MM-dd格式的字符串转java.util.Date。解决是在实体字段上加@DateTimeFormat(pattern = "yyyy-MM-dd"),如果是@RequestBody接 JSON,则要用@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")。两个注解管的是两条不同的链路,少配一个就在另一种提交方式上翻车。

注意:timezone = "GMT+8"不加的话,JSON 反序列化出来的日期会少 8 小时,显示成前一天。这个偏差在只存日期不存时间的字段上看不出来,一旦存了时间就暴露。

第三条:@Transactional加了但没回滚。

现象是选课失败后课程名额已经扣了,选课记录却没插进去,数据对不上。原因常见的有三种:方法被同类内部调用(自调用不经过代理,事务不生效)、异常被try-catch吞掉没往外抛、Service 类没被 Spring 扫描到而是手动new出来的。解决是自调用改成注入自身代理或者把方法拆到另一个 Bean;catch 块里要么重新抛出要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();确认@Service注解和包扫描路径对得上。还有一种更隐蔽的:表引擎是 MyISAM,压根不支持事务,建表时统一写ENGINE=InnoDB。

第四条:CSS、JS 全部 404,页面裸奔。

现象是登录页能打开但没有任何样式,F12 里一堆静态资源 404。原因是web.xml里DispatcherServlet配了/拦截所有请求,静态资源也被当成 Controller 请求处理了。解决是在spring-mvc.xml里加<mvc:resources mapping="/css/**" location="/css/"/>,或者加<mvc:default-servlet-handler/>把找不到的请求交回给容器默认 Servlet。两个方案选一个即可,混用有时会互相覆盖。

第五条:跑一段时间后报连接池耗尽。

现象是本地测试好好的,演示几分钟后页面开始转圈,日志出现Too many connections或wait timeout。原因是 Dao 层用了DriverManager.getConnection手动开连接,异常分支里没关;或者连接池maxActive配得太小、maxWait太短。解决是统一走连接池(Druid 或 HikariCP),所有Connection、Statement、ResultSet用 try-with-resources 包起来;连接串加autoReconnect=true只能救急,根子还是泄漏。我习惯在测试阶段把maxActive调到 5,跑一轮完整业务流程,如果很快就耗尽,说明一定有连接没关。

5.2 排错顺序:从日志到 SQL 的定位路径

遇到 500 别急着改代码,按固定顺序看一遍能省很多时间。先看 Tomcat 控制台最上面的Caused by,那才是根因,下面一长串调用栈只是路径。如果是NullPointerException,往上找第一个属于自己项目包名的类,八成是某个get返回了 null 没判。如果是 SQL 相关异常,把日志里打印的那条 SQL 复制出来,参数手动填进去在客户端跑一遍,能立刻区分是 SQL 写错还是参数传错。

再往下是看 MyBatis 的log4j输出。把logging.level调到 DEBUG,控制台会打出Preparing和Parameters两行,前者是带?的 SQL,后者是实际参数值。参数顺序和?对不上,是最常见的「SQL 在客户端能跑、在项目里跑不了」的原因。

最后一招是把断点打在 Controller 方法入口。请求到底有没有进到 Controller,决定了你该查前端还是查后端。没进来,检查拦截器、路径映射、请求方式;进来了,就顺着 Service、Mapper 一层层往下看。这个顺序比看堆栈猜要稳。

6. 二次开发与验收:把课程设计改成能过答辩的作品

原始项目能跑通只是及格线。我见过太多人直接拿原版去答辩,被问「你改了什么」就卡住。改造不需要大动,选两三个点做深比铺十个功能有用。

答辩前按这张表过一遍,每一项都能当场演示:

检查项验证方式常见扣分点
角色权限用学生账号直接访问/admin/student/list只做菜单隐藏,接口没拦
选课并发两个浏览器同时提交同一门满员课只用count判断,超卖
事务一致性选课失败后查course.selected名额扣了记录没插
分页边界页码传 0、传 999查不出数据或全表返回
数据校验学号填空、填字母、填超长只靠前端 required

改造方向我给两个具体的。一是把 3.2 节的选课逻辑从「先查后改」重构成条件 UPDATE,这个改动代码量不到二十行,但能讲清楚行锁和原子性,答辩时是实打实的加分项。二是给成绩模块加一个「班级不及格率排名」的统计页,一条GROUP BY加HAVING就能出结果,配上 ECharts 横向柱状图,演示效果比纯表格好得多。这两处都不需要引入新框架,风险低,讲得清。

有个习惯我保持了几年:不管拿到的是谁的代码,第一次跑通之后先不急着改业务,而是把git init建一个本地仓库提交一次原始版本。之后每改一个模块提交一次,改崩了随时能回到上一个能跑的状态。早期我不做这一步,改着改着分不清哪个文件是自己的改动、哪个是原项目本来就有的 bug,最后只能重新解压一遍从头来。这份后悔药成本极低,建议你也这么做。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进